v0.0.72 - Refined wizard framing and menu hotkey display; diagnosed hardcoded Didactyl fallback causing name mismatch

This commit is contained in:
Your Name
2026-03-16 14:37:04 -04:00
parent 5857058a21
commit 0cc0667a80
43 changed files with 25348 additions and 18627 deletions

2
.gitignore vendored
View File

@@ -9,7 +9,7 @@
test_keys.txt
/mongoose/
/Trash/
# Build artifacts

View File

@@ -100,8 +100,8 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \
src/tools/tool_nostr_query.c src/tools/tool_nostr_identity.c src/tools/tool_nostr_social.c \
src/tools/tool_nostr_relay.c src/tools/tool_nostr_dm.c src/tools/tool_admin.c \
src/tools/tool_task.c src/tools/tool_nostr_list.c src/tools/tool_local.c \
src/tools/tool_skill.c src/tools/tool_nostr_post.c src/trigger_manager.c \
src/prompt_template.c src/http_api.c src/mongoose.c src/debug.c \
src/tools/tool_skill.c src/tools/tool_nostr_post.c src/tools/tool_memory.c src/tools/tool_config.c src/trigger_manager.c \
src/prompt_template.c src/http_api.c src/setup_wizard.c src/mongoose.c src/debug.c \
-o /build/didactyl_static \
nostr_core_lib/libnostr_core_x64.a \
-lsecp256k1 \

View File

@@ -28,9 +28,12 @@ SRCS = \
$(SRC_DIR)/tools/tool_local.c \
$(SRC_DIR)/tools/tool_skill.c \
$(SRC_DIR)/tools/tool_nostr_post.c \
$(SRC_DIR)/tools/tool_memory.c \
$(SRC_DIR)/tools/tool_config.c \
$(SRC_DIR)/trigger_manager.c \
$(SRC_DIR)/prompt_template.c \
$(SRC_DIR)/http_api.c \
$(SRC_DIR)/setup_wizard.c \
$(SRC_DIR)/mongoose.c \
$(SRC_DIR)/debug.c

162
README.md
View File

@@ -49,25 +49,25 @@ Agents learn capabilities through skills — Nostr events that any agent can dis
Think of it like a woodshop: a **skill** is knowing how to carve — the technique, the judgment, the decision-making. A **tool** is the chisel. The skill never directly uses the chisel without the craftsperson (the LLM) in the loop. Every skill execution involves the LLM reasoning about what to do and which tools to use.
Skills support context modes (`inject`, `full`, `override`) and per-skill LLM fallback chains (for example: `anthropic/claude-sonnet-4-20250514, openai/gpt-4o-mini, cheap`) so each skill can tune behavior and cost. See [`docs/SKILLS.md`](docs/SKILLS.md).
Skills compose by adoption-list order (`10123`) and trigger tags carry runtime execution controls (`llm`, `max_tokens`, `temperature`, `seed`, `tools`) so behavior and runtime policy remain explicit and portable. See [`docs/SKILLS.md`](docs/SKILLS.md).
### Private inference.
Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers.
## Current Status — v0.0.71
## Current Status — v0.0.72
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.0.71Add nprofile, nevent, and naddr support to nostr_encode with argument validation
> Last release update: v0.0.72Refined wizard framing and menu hotkey display; diagnosed hardcoded Didactyl fallback causing name mismatch
- Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected
- Uses kind `31120` startup content as live Soul at boot
- 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 soul template (`---template---` section in kind `31120`) with named sections, variable resolution, and per-provider content overrides; falls back to hardcoded assembly if no template present
- 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
- 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)
@@ -84,7 +84,7 @@ Didactyl will support local inference, which is very privacy preserving. Remote
```bash
chmod +x ./didactyl_static_x86_64
./didactyl_static_x86_64 --config ./config.jsonc
./didactyl_static_x86_64 --config ./genesis.jsonc
```
### Build from source (optional)
@@ -103,7 +103,7 @@ chmod +x ./didactyl_static_x86_64
### Configure
Edit [`config.jsonc`](config.jsonc):
Edit [`genesis.jsonc`](genesis.jsonc):
```json
{
@@ -155,9 +155,9 @@ Edit [`config.jsonc`](config.jsonc):
"tags": [["r", "wss://relay.damus.io"], ["r", "wss://nos.lol"]]
},
{
"kind": 31120,
"kind": 31124,
"content": "You are Didactyl...",
"tags": [["d", "soul"], ["app", "didactyl"], ["scope", "private"]]
"tags": [["d", "didactyl-default"], ["app", "didactyl"], ["scope", "private"]]
},
{
"kind": 31123,
@@ -180,18 +180,32 @@ Relays are sourced exclusively from startup kind `10002` `r` tags.
### Run
```bash
./didactyl_static_x86_64 --config ./config.jsonc
# interactive setup wizard (new / existing / load-genesis)
./didactyl_static_x86_64
# direct startup from config
./didactyl_static_x86_64 --config ./genesis.jsonc
```
Options:
```
./didactyl_static_x86_64 --config <path> # custom config file (default: ./config.jsonc)
./didactyl_static_x86_64 --debug <0-5> # log verbosity (0 none, 3 info, 5 trace)
./didactyl_static_x86_64 --dump-schemas # print tool JSON schemas and exit
./didactyl_static_x86_64 --test-tool <name> <args_json> # run one tool directly and print JSON result
./didactyl_static_x86_64 # interactive setup mode (when run with no arguments)
./didactyl_static_x86_64 --config <path> # custom config file (default: ./genesis.jsonc)
./didactyl_static_x86_64 --nsec <nsec_or_hex> # runtime identity seed override (or use DIDACTYL_NSEC)
./didactyl_static_x86_64 --api-port <port> # runtime local API port override
./didactyl_static_x86_64 --api-bind <address> # runtime local API bind override
./didactyl_static_x86_64 --debug <0-5> # log verbosity (0 none, 3 info, 5 trace)
./didactyl_static_x86_64 --dump-schemas # print tool JSON schemas and exit
./didactyl_static_x86_64 --test-tool <name> <args_json> # run one tool directly and print JSON result
```
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.
- Existing-agent mode attempts to recover relay/admin/LLM config from Nostr before asking for missing fields.
CLI debugger notes:
- `--test-tool` initializes Nostr, waits for at least one relay connection (up to 15s), then executes the selected tool.
@@ -199,7 +213,7 @@ CLI debugger notes:
- Example:
```bash
./didactyl_static_x86_64 --config ./config.jsonc --test-tool nostr_file_md_to_longform_post '{"file":"docs/SKILLS.md","title":"SKILLS"}'
./didactyl_static_x86_64 --config ./genesis.jsonc --test-tool nostr_file_md_to_longform_post '{"file":"docs/SKILLS.md","title":"SKILLS"}'
```
### Talk to it
@@ -262,20 +276,15 @@ 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.
- `31120`**Soul** (private instruction baseline)
- `d=soul`
- `31123`**Public Skill Definition** (replaceable by `d` tag)
- `content` is JSON with fields like `description`, `context_mode`, `llm`, `tools`, `template`, optional `max_tokens` / `temperature`
- `content` is JSON with instruction fields like `description` and `template`
- `d=<skill_slug>` (example: `d=long_form_note`)
- `31124`**Private Skill Definition** (same schema as `31123`, private scope)
- `d=<skill_slug>` (example: `d=admin_ops`)
- `10123`**Skill Adoption List**
- tags contain one or more `a` references to selected skills
Context modes:
- `inject` — skill instructions are layered into soul context
- `full` — skill provides full prompt template (soul optional via `{{soul}}`)
- `override` — skill replaces soul prompt, standard context structure remains
Skills are composed by adoption list order and per-skill template resolution (no context modes).
Full skill schema, trigger tags, template variables, fallback resolution, and limits are documented in [`docs/SKILLS.md`](docs/SKILLS.md).
@@ -299,77 +308,38 @@ Skills are shared across Nostr without any centralized registry or approval proc
## Startup
Didactyl startup behavior is configured in [`config.jsonc`](config.jsonc) under `startup_events`.
Didactyl startup behavior is configured in [`genesis.jsonc`](genesis.jsonc) under `startup_events`.
Also used at startup:
Startup model:
- `0` — profile metadata
- `10002` relay list
- `1` — optional startup note/status
- `3` — contacts/follows (optional placeholder)
- 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`.
On boot, Didactyl attempts startup publishes to each relay as that relay transitions to connected state.
See [`docs/GENESIS.md`](docs/GENESIS.md) for full boot semantics.
## Runtime Context Model
Didactyl builds tier-aware context:
Didactyl builds tier-aware, template-driven context:
- **ADMIN** request context assembled from the soul's `---template---` section (if present), otherwise hardcoded order:
1. Soul personality (everything above `---template---` in kind `31120`)
2. Named template sections in order using `tool:` directives (for example `nostr_admin_profile`, `nostr_admin_notes`, `task_list`, `message_current`, `dm_history`)
3. Each section executes its configured context tool, optionally extracting `result_field` (default: `content`)
4. Provider-specific content overrides per section remain supported for literal `content:` sections
5. Section names are used in `context.log` headers and `/api/context/parts` response
- **WoT** request context: Soul + WoT chat-only instruction + current user message (no tools)
- **STRANGER**: no LLM call when configured to reply statically
- **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`).
- **WoT** request context remains chat-only.
- **STRANGER** behavior follows configured security policy.
Every serialized LLM context payload is appended to [`context.log`](context.log).
Triggered skills and tool loops are bounded by runtime safeguards (for example, trigger cooldowns and action rate limits); see [`docs/SKILLS.md`](docs/SKILLS.md) for the current defaults.
See [`docs/CONTEXT.md`](docs/CONTEXT.md) and [`docs/SKILLS.md`](docs/SKILLS.md) for the normative assembly and trigger rules.
## Tooling Interface
Current tool schema exposed to the LLM in [`tools_build_openai_schema_json()`](src/tools.c:881):
The OpenAI-compatible tool schema is generated in [`src/tools/tools_schema.c`](src/tools/tools_schema.c), and runtime dispatch is handled in [`src/tools/tools_dispatch.c`](src/tools/tools_dispatch.c).
- Nostr publish/query:
- `nostr_post`
- `nostr_post_readme`
- `nostr_query`
- Nostr interaction and moderation:
- `nostr_delete`
- `nostr_react`
- `nostr_profile_get`
- `nostr_relay_status`
- `nostr_relay_info`
- `nostr_nip05_lookup`
- Nostr encode/decode + encryption/DM:
- `nostr_encode`
- `nostr_decode`
- `nostr_encrypt`
- `nostr_decrypt`
- `nostr_dm_send`
- `nostr_dm_send_nip17`
- Nostr list management:
- `nostr_list_manage`
- Skill management:
- `skill_create`
- `skill_list`
- `skill_adopt`
- `skill_remove`
- `skill_search`
- Local/host tools:
- `local_shell_exec`
- `local_file_read`
- `local_file_write`
- `local_http_fetch`
- Agent metadata:
- `agent_version`
- Model management:
- `model_get`
- `model_set`
- `model_list`
Core categories include Nostr publish/query, identity/profile operations, DM and list management, skill operations, local host tools, model/runtime controls, and encrypted config tools (`config_store`, `config_recall`).
Execution entrypoint: [`tools_execute()`](src/tools.c:3765).
See [`docs/TOOLS.md`](docs/TOOLS.md) for the canonical tool catalog and interface details.
## HTTP Admin API
@@ -384,7 +354,7 @@ A localhost-only HTTP API on port `8484` (configurable) for agent inspection and
| `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 (persists to config.jsonc) |
| `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).
@@ -393,7 +363,7 @@ Full reference: [`docs/API.md`](docs/API.md). Frontend brief: [`plans/admin_web_
```
.
├── config.jsonc # Agent/runtime config (JSONC with comments) including startup_events + tools
├── genesis.jsonc # First-run genesis config (startup events + baseline runtime settings)
├── context.log # Appended outbound LLM context payloads
├── Makefile # Build system
├── build_static.sh # Preferred final build validation
@@ -402,8 +372,8 @@ Full reference: [`docs/API.md`](docs/API.md). Frontend brief: [`plans/admin_web_
│ ├── config.c / .h # JSON config parsing, key decode, startup events
│ ├── context.c / .h # File loader utility (reads file into malloc'd string)
│ ├── agent.c / .h # Context assembly, tool loop, DM response flow
│ ├── prompt_template.c / .h # Soul template parser, variable resolver, context builder
│ ├── tools.c / .h # LLM tool schema and tool execution
│ ├── prompt_template.c / .h # Skill template parser, variable resolver, context builder
│ ├── tools/ # LLM tool schema and tool execution modules
│ ├── llm.c / .h # LLM HTTP API client (OpenAI-compatible)
│ ├── nostr_handler.c / .h # Relay pool, subscriptions, publish, startup reconcile
│ ├── trigger_manager.c / .h # Nostr event trigger subscriptions and skill execution
@@ -413,7 +383,7 @@ Full reference: [`docs/API.md`](docs/API.md). Frontend brief: [`plans/admin_web_
├── docs/
│ ├── API.md # HTTP admin API endpoint reference
│ ├── TOOLS.md # Tool architecture and catalog
│ ├── SKILLS.md # Skill schema, context modes, triggers, and limits
│ ├── SKILLS.md # Skill schema, composition model, triggers, and limits
│ └── CRASH_FIXES.md # Crash analysis and fixes log
├── plans/ # Architecture and planning documents
└── README.md
@@ -435,9 +405,9 @@ All dependencies are statically linked into the binary at build time. No system
Didactyl's long-term architecture goal is **zero filesystem dependency after first boot**. The config file is the only tie to the local filesystem. The plan:
1. **First boot** — Read `config.jsonc`, publish all identity, soul, skills, and adoption list as Nostr events to relays.
2. **Subsequent boots**Given only the agent's keys, retrieve everything needed from Nostr relays: soul, skills, adoption list, trigger definitions, admin pubkey, relay list. No config file required.
3. **True portability** — Start your agent from any computer. All you need are its keys. All state lives on Nostr.
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.
@@ -451,17 +421,17 @@ This makes Didactyl fundamentally different from filesystem-bound agents. Destro
| Public skills | Kind 31123 | Implemented |
| Private skills | Kind 31124 | Implemented |
| Skill adoption list | Kind 10123 | Implemented |
| Soul/personality | Kind 31120 | 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 | `config.jsonc` | Derive from kind 3 contact list or dedicated config event |
| LLM provider/key | `config.jsonc` | Encrypted kind 30078 app-specific event or NIP-78 |
| Security tiers | `config.jsonc` | Agent config event on Nostr |
| API settings | `config.jsonc` | Local-only — stays on filesystem as runtime flag |
| 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
@@ -472,7 +442,7 @@ This makes Didactyl fundamentally different from filesystem-bound agents. Destro
- [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 (`31120` Soul, `31123` Public Skill, `31124` Private Skill)
- [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)
@@ -481,13 +451,13 @@ This makes Didactyl fundamentally different from filesystem-bound agents. Destro
- [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 (persists to config.jsonc)
- [x] Soul-embedded prompt templates (`---template---`) — configurable context order, variable resolution, provider overrides
- [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
- [ ] NIP-44 encrypted private skills (`31124`)
- [ ] 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

View File

@@ -1,4 +1,8 @@
{
// LEGACY CONFIG EXAMPLE
// This file is deprecated in favor of genesis.jsonc + nsec-only startup.
// Kept as reference for older deployments and migration support.
// ─── Agent Identity Keys ───────────────────────────────────────────
// Your agent's Nostr keypair. Provide nsec (bech32) or hex format.
// The public key fields are optional — they are derived automatically.
@@ -155,7 +159,7 @@
// structured context sections that are injected at runtime.
{
"kind": 31120,
"content": "# Didactyl Agent\n\nYou are Didactyl, a sovereign AI agent living on Nostr.\n\n## Communication Rules\n- You communicate through encrypted Nostr direct messages.\n- Keep responses concise and clear.\n\n## Behavior\n- Be helpful and technically accurate.\n- If unsure, state uncertainty directly.\n- Prefer actionable, practical advice.\n- Use the person's name when messaging them if you know it.\n- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n## Tool Use Policy\n- You have tools available and should use them when a request requires taking action.\n- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.\n- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n- After a tool call, base your answer on the actual tool result.\n- Never claim a tool was run if no tool was executed.\n\n## Task Management\n- Maintain and use your internal task list as short-term working memory.\n- Break long or complex actions into clear tasks before executing them.\n- Update task status as you complete steps so your plan stays accurate.\n\n## Safety\n- Do not claim to have executed actions you did not execute.\n- You may share your public key (npub) with anyone.\n- Never reveal your private key (nsec) under any circumstance.\n\n---template---\n\n- section: admin_identity\n role: system\n content: |\n ## Administrator Identity (source: config.admin.pubkey)\n\n This is your administrator! Admin pubkey (hex): {{admin_pubkey}}\n\n- section: admin_profile\n role: system\n content: |\n ## Administrator Kind 0 Profile (source: nostr kind 0)\n\n Administrator kind 0 profile content (JSON): {{admin_kind0_json}}\n provider:\n anthropic: |\n <admin_kind0_profile source=\"nostr_kind_0\">\n {{admin_kind0_json}}\n </admin_kind0_profile>\n\n- section: admin_contacts\n role: system\n content: |\n ## Administrator Contact List (source: nostr kind 3)\n\n Administrator kind 3 contact list pubkeys (JSON array): {{admin_kind3_json}}\n provider:\n anthropic: |\n <admin_contacts source=\"nostr_kind_3\">\n {{admin_kind3_json}}\n </admin_contacts>\n\n- section: admin_relays\n role: system\n content: |\n ## Administrator Relay List (source: nostr kind 10002)\n\n Administrator kind 10002 relay list (JSON): {{admin_kind10002_json}}\n provider:\n anthropic: |\n <admin_relays source=\"nostr_kind_10002\">\n {{admin_kind10002_json}}\n </admin_relays>\n\n- section: admin_notes\n role: system\n content: |\n ## Administrator Recent Notes (source: nostr kind 1)\n\n Administrator recent kind 1 notes (JSON array): {{admin_kind1_json}}\n provider:\n anthropic: |\n <admin_notes source=\"nostr_kind_1\">\n {{admin_kind1_json}}\n </admin_notes>\n\n- section: skills\n role: system\n content: |\n ## Adopted Skills\n\n {{skills_json}}\n\n- section: tasks\n role: system\n content: |\n ## Current Task List\n\n {{tasks_json}}\n\n- section: conversation\n role: conversation\n content: |\n {{conversation}}",
"content": "# Didactyl Agent\n\nYou are Didactyl, a sovereign AI agent living on Nostr.\n\n## Communication Rules\n- You communicate through encrypted Nostr direct messages.\n- Keep responses concise and clear.\n\n## Behavior\n- Be helpful and technically accurate.\n- If unsure, state uncertainty directly.\n- Prefer actionable, practical advice.\n- Use the person's name when messaging them if you know it.\n- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n## Tool Use Policy\n- You have tools available and should use them when a request requires taking action.\n- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.\n- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n- For memory persistence, use `memory_save` to prepend new memory entries and `memory_recall` to recall stored memory.\n- Memory is not injected by default; call `memory_recall` when needed.\n- Keep memory content in markdown and concise enough for context efficiency.\n- After a tool call, base your answer on the actual tool result.\n- Never claim a tool was run if no tool was executed.\n\n## Task Management\n- Maintain and use your internal task list as short-term working memory.\n- Break long or complex actions into clear tasks before executing them.\n- Update task status as you complete steps so your plan stays accurate.\n\n## Safety\n- Do not claim to have executed actions you did not execute.\n- You may share your public key (npub) with anyone.\n- Never reveal your private key (nsec) under any circumstance.\n\n---template---\n\n- section: admin_identity\n role: system\n content: |\n ## Administrator Identity (source: config.admin.pubkey)\n\n This is your administrator! Admin pubkey (hex): {{admin_pubkey}}\n\n- section: admin_profile\n role: system\n content: |\n ## Administrator Kind 0 Profile (source: nostr kind 0)\n\n Administrator kind 0 profile content (JSON): {{admin_kind0_json}}\n provider:\n anthropic: |\n <admin_kind0_profile source=\"nostr_kind_0\">\n {{admin_kind0_json}}\n </admin_kind0_profile>\n\n- section: admin_contacts\n role: system\n content: |\n ## Administrator Contact List (source: nostr kind 3)\n\n Administrator kind 3 contact list pubkeys (JSON array): {{admin_kind3_json}}\n provider:\n anthropic: |\n <admin_contacts source=\"nostr_kind_3\">\n {{admin_kind3_json}}\n </admin_contacts>\n\n- section: admin_relays\n role: system\n content: |\n ## Administrator Relay List (source: nostr kind 10002)\n\n Administrator kind 10002 relay list (JSON): {{admin_kind10002_json}}\n provider:\n anthropic: |\n <admin_relays source=\"nostr_kind_10002\">\n {{admin_kind10002_json}}\n </admin_relays>\n\n- section: admin_notes\n role: system\n content: |\n ## Administrator Recent Notes (source: nostr kind 1)\n\n Administrator recent kind 1 notes (JSON array): {{admin_kind1_json}}\n provider:\n anthropic: |\n <admin_notes source=\"nostr_kind_1\">\n {{admin_kind1_json}}\n </admin_notes>\n\n- section: skills\n role: system\n content: |\n ## Adopted Skills\n\n {{skills_json}}\n\n- section: tasks\n role: system\n content: |\n ## Current Task List\n\n {{tasks_json}}\n\n- section: conversation\n role: conversation\n content: |\n {{conversation}}",
"tags": [
["d", "soul"],
["app", "didactyl"],

File diff suppressed because one or more lines are too long

View File

@@ -1,67 +1,10 @@
# Context Template
# Context Template (Legacy)
```yaml
- section: admin_identity
role: system
tool: admin_identity
skip_if_empty: true
This file is deprecated and retained for backward compatibility only.
- section: admin_profile
role: system
tool: nostr_admin_profile
skip_if_empty: true
The default context behavior is now defined by adopted skills (kind `10123`) and the `didactyl_default` skill template published from `genesis.jsonc`.
- section: admin_contacts
role: system
tool: nostr_admin_contacts
skip_if_empty: true
- section: admin_relays
role: system
tool: nostr_admin_relays
skip_if_empty: true
- section: admin_notes
role: system
tool: nostr_admin_notes
skip_if_empty: true
- section: agent_identity
role: system
tool: agent_identity
skip_if_empty: true
- section: agent_profile
role: system
tool: nostr_agent_profile
skip_if_empty: true
- section: agent_contacts
role: system
tool: nostr_agent_contacts
skip_if_empty: true
- section: agent_relays
role: system
tool: nostr_agent_relays
skip_if_empty: true
- section: agent_notes
role: system
tool: nostr_agent_notes
skip_if_empty: true
- section: tasks
role: system
tool: task_list
skip_if_empty: true
- section: conversation
role: user
tool: message_current
skip_if_empty: true
- section: dm_history
role: expand
limit: 12
```
For current behavior, see:
- `docs/GENESIS.md`
- `docs/CONTEXT.md`
- `docs/SKILLS.md`

10810
didactyl.log

File diff suppressed because one or more lines are too long

124
didactyl.nsec.debug.log Normal file
View File

@@ -0,0 +1,124 @@
[2026-03-16 05:30:19] [INFO ] Didactyl v0.0.71 starting
[2026-03-16 05:30:19] [INFO ] [didactyl] initializing relay pool with 2 relays
[2026-03-16 05:30:19] [INFO ] [didactyl] added relay: wss://relay.damus.io
[2026-03-16 05:30:19] [INFO ] [didactyl] added relay: wss://relay.primal.net
[2026-03-16 05:30:19] [INFO ] [didactyl] startup checklist [01] Relay connectivity: begin
[startup 01] Relay connectivity ...
[2026-03-16 05:30:20] [WARN ] [didactyl] poll latency spike: nostr_relay_pool_poll(timeout=100) took 1190.6ms rc=0 count=1
[2026-03-16 05:30:20] [INFO ] [didactyl] relay state changed: wss://relay.damus.io disconnected -> connected
[2026-03-16 05:30:20] [INFO ] [didactyl] relay state changed: wss://relay.primal.net disconnected -> connected
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [01] Relay connectivity: ok (connected relays: 2/2)
[startup 01] Relay connectivity: OK (connected relays: 2/2)
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [02] Recover runtime config from Nostr: begin
[startup 02] Recover runtime config from Nostr ...
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [02] Recover runtime config from Nostr: ok
[startup 02] Recover runtime config from Nostr: OK
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [03] Validate LLM config: begin
[startup 03] Validate LLM config ...
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [03] Validate LLM config: ok
[startup 03] Validate LLM config: OK
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [04] Validate admin config: begin
[startup 04] Validate admin config ...
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [04] Validate admin config: ok
[startup 04] Validate admin config: OK
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [05] Initialize LLM client: begin
[startup 05] Initialize LLM client ...
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [05] Initialize LLM client: ok
[startup 05] Initialize LLM client: OK
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [06] Detect first run via kind 10002: begin
[startup 06] Detect first run via kind 10002 ...
[2026-03-16 05:30:20] [INFO ] [didactyl] startup phase: first-run detection via kind 10002 => subsequent-run
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [06] Detect first run via kind 10002: ok (subsequent-run)
[startup 06] Detect first run via kind 10002: OK (subsequent-run)
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [07] Reconcile/persist startup state: begin
[startup 07] Reconcile/persist startup state ...
[2026-03-16 05:30:20] [INFO ] [didactyl] startup phase: skipping genesis startup-event publish on subsequent run
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [07] Reconcile/persist startup state: ok
[startup 07] Reconcile/persist startup state: OK
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [08] Initialize agent: begin
[startup 08] Initialize agent ...
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [08] Initialize agent: ok
[startup 08] Initialize agent: OK
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [09] Initialize trigger manager: begin
[startup 09] Initialize trigger manager ...
[2026-03-16 05:30:21] [INFO ] [didactyl] trigger manager initialized (capacity=16)
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [09] Initialize trigger manager: ok
[startup 09] Initialize trigger manager: OK
[2026-03-16 05:30:21] [INFO ] [didactyl] startup phase: deferred trigger load will run after self-skill EOSE
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [10] Load startup triggers: begin
[startup 10] Load startup triggers ...
[2026-03-16 05:30:21] [INFO ] [didactyl] startup phase: startup-config trigger load begin
[2026-03-16 05:30:21] [INFO ] [didactyl] trigger manager loaded 0 trigger(s) from startup config (considered=1)
[2026-03-16 05:30:21] [INFO ] [didactyl] startup phase: startup-config trigger load end
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [10] Load startup triggers: ok
[startup 10] Load startup triggers: OK
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [11] Discover self relay list via kind 10002: begin
[startup 11] Discover self relay list via kind 10002 ...
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 query begin: timeout_ms=5000 author=52a3e82f7b374385... relay_count=2
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 query relay snapshot: {"relay_count":2,"connected_count":2,"relays":[{"url":"wss://relay.damus.io","status":"connected","events_received":3,"events_published":0,"events_published_ok":0,"events_published_failed":0,"ping_latency_current":0,"ping_latency_avg":0,"query_latency_avg":0,"publish_latency_avg":0,"connection_uptime_start":1773653419,"last_event_time":0},{"url":"wss://relay.primal.net","status":"connected","events_received":2,"events_published":0,"events_published_ok":0,"events_published_failed":0,"ping_latency_current":0,"ping_latency_avg":0,"query_latency_avg":0,"publish_latency_avg":0,"connection_uptime_start":1773653419,"last_event_time":0}]}
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 query received event_count=1
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 query extracted relays=["wss://relay.damus.io","wss://nos.lol","wss://relay.primal.net","ws://127.0.0.1:7777","wss://relay.laantungir.net","wss://sendit.nosflare.com"]
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 wait success: relay_count=6
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 relay[0]=wss://relay.damus.io
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 relay[1]=wss://nos.lol
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 relay[2]=wss://relay.primal.net
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 relay[3]=ws://127.0.0.1:7777
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 relay[4]=wss://relay.laantungir.net
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 relay[5]=wss://sendit.nosflare.com
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 wait success relay snapshot: {"relay_count":2,"connected_count":2,"relays":[{"url":"wss://relay.damus.io","status":"connected","events_received":4,"events_published":0,"events_published_ok":0,"events_published_failed":0,"ping_latency_current":0,"ping_latency_avg":0,"query_latency_avg":0,"publish_latency_avg":0,"connection_uptime_start":1773653419,"last_event_time":0},{"url":"wss://relay.primal.net","status":"connected","events_received":2,"events_published":0,"events_published_ok":0,"events_published_failed":0,"ping_latency_current":0,"ping_latency_avg":0,"query_latency_avg":0,"publish_latency_avg":0,"connection_uptime_start":1773653419,"last_event_time":0}]}
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [11] Discover self relay list via kind 10002: ok (kind10002=6 added=4 connected=2/6)
[startup 11] Discover self relay list via kind 10002: OK (kind10002=6 added=4 connected=2/6)
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [12] Subscribe admin context: begin
[startup 12] Subscribe admin context ...
[2026-03-16 05:30:21] [INFO ] [didactyl] startup phase: subscribe admin context begin
[2026-03-16 05:30:26] [INFO ] [didactyl] admin context subscriptions active for admin 8ff74724ed641b3c...
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe admin context end
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [12] Subscribe admin context: ok
[startup 12] Subscribe admin context: OK
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [13] Subscribe agent self context: begin
[startup 13] Subscribe agent self context ...
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe agent self context begin
[2026-03-16 05:30:26] [INFO ] [didactyl] agent self-context subscriptions active for pubkey 52a3e82f7b374385...
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe agent self context end
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [13] Subscribe agent self context: ok
[startup 13] Subscribe agent self context: OK
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [14] Subscribe self-skill cache: begin
[startup 14] Subscribe self-skill cache ...
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe self skill cache begin
[2026-03-16 05:30:26] [INFO ] [didactyl] self/admin skill subscriptions active (self=52a3e82f7b374385..., admin=8ff74724ed641b3c...)
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe self skill cache end
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [14] Subscribe self-skill cache: ok
[startup 14] Subscribe self-skill cache: OK
[2026-03-16 05:30:26] [INFO ] [didactyl] publish DM target relays (6):
[2026-03-16 05:30:26] [INFO ] [didactyl] -> wss://relay.damus.io
[2026-03-16 05:30:26] [INFO ] [didactyl] -> wss://relay.primal.net
[2026-03-16 05:30:26] [INFO ] [didactyl] -> wss://nos.lol
[2026-03-16 05:30:26] [INFO ] [didactyl] -> ws://127.0.0.1:7777
[2026-03-16 05:30:26] [INFO ] [didactyl] -> wss://relay.laantungir.net
[2026-03-16 05:30:26] [INFO ] [didactyl] -> wss://sendit.nosflare.com
[2026-03-16 05:30:26] [INFO ] [didactyl] kind 4 event published to wss://relay.damus.io (async)
[2026-03-16 05:30:26] [INFO ] [didactyl] kind 4 event published to wss://relay.primal.net (async)
[2026-03-16 05:30:26] [INFO ] [didactyl] kind 4 event published to wss://nos.lol (async)
[2026-03-16 05:30:26] [INFO ] [didactyl] kind 4 event published to ws://127.0.0.1:7777 (async)
[2026-03-16 05:30:26] [INFO ] [didactyl] kind 4 event published to wss://relay.laantungir.net (async)
[2026-03-16 05:30:26] [INFO ] [didactyl] kind 4 event published to wss://sendit.nosflare.com (async)
[2026-03-16 05:30:26] [INFO ] [didactyl] sent DM 54452dbf320bee66... to 8ff74724ed641b3c... via 6 connected relay(s)
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [15] Subscribe DMs: begin
[startup 15] Subscribe DMs ...
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe DMs begin
[2026-03-16 05:30:26] [INFO ] [didactyl] DM subscription active for pubkey 52a3e82f7b374385...
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe DMs end
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [15] Subscribe DMs: ok
[startup 15] Subscribe DMs: OK
2aa88330 3 mongoose.c:4824:mg_mgr_init MG_IO_SIZE: 16384, TLS: builtin
2aa88331 3 mongoose.c:4741:mg_listen 1 11 https://127.0.0.1:8484
[2026-03-16 05:30:26] [INFO ] [didactyl] http api listening on https://127.0.0.1:8484
[2026-03-16 05:30:26] [INFO ] [didactyl] HTTP API listening at http://127.0.0.1:8484
[2026-03-16 05:30:26] [INFO ] [didactyl] HTTP API endpoints: http://127.0.0.1:8484/api/context/current http://127.0.0.1:8484/api/context/parts
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [16] READY: ok (agent online; entering main poll loop)
[startup 16] READY: OK (agent online; entering main poll loop)
[2026-03-16 05:30:26] [INFO ] [didactyl] entering main poll loop
[2026-03-16 05:30:26] [INFO ] [didactyl] running with pubkey 52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8
[2026-03-16 05:30:27] [INFO ] [didactyl] live self-skill trigger ignored (not adopted) d_tag=daily-readme-publish
[2026-03-16 05:30:28] [INFO ] [didactyl] live self-skill trigger ignored (not adopted) d_tag=daily-readme-publish
[2026-03-16 05:30:28] [INFO ] [didactyl] live self-skill trigger ignored (not adopted) d_tag=daily-readme-publish

View File

@@ -178,7 +178,7 @@ Returns the context broken into labeled, individually-sized parts. Useful for un
| Name | Description |
|---|---|
| `system_prompt` | The agent soul / system prompt (first system message) |
| `system_prompt` | The agent base/default skill prompt (first system message) |
| `admin_identity` | Admin pubkey identification message |
| `admin_kind0` | Admin kind 0 profile metadata |
| `admin_relay_list` | Admin kind 10002 relay list |
@@ -513,8 +513,6 @@ The following endpoints are planned but not yet implemented:
| Method | Path | Description |
|---|---|---|
| GET | `/api/config` | Current runtime config with redacted secrets |
| GET | `/api/events/soul` | Fetch agent soul event |
| PUT | `/api/events/soul` | Update soul content |
| GET | `/api/events/skills` | List published skills |
| GET | `/api/events/skills/:d_tag` | Fetch skill by d_tag |
| PUT | `/api/events/skills/:d_tag` | Update skill |

View File

@@ -4,32 +4,26 @@ See also: [SKILLS.md](SKILLS.md) · [TOOLS.md](TOOLS.md)
## What Is Context?
Every time Didactyl talks to an LLM, it sends a **context** — the complete package of information the model needs to reason and respond. Understanding context is fundamental to understanding how Didactyl works.
Every time Didactyl talks to an LLM, it sends a **context** — the complete package of information the model needs to reason and respond.
Context is not just "the prompt." It is everything the LLM receives in a single request:
Context is not just a prompt string; it is the full request payload:
1. **Messages**the conversation: system prompts, user messages, assistant responses, tool results
2. **Tool schemas** the JSON descriptions of every tool the LLM can call
3. **Model parameters** — temperature, max tokens, etc.
The LLM sees all of this together and produces its response based on the totality of what it was given.
1. **Messages**system/user/assistant/tool history
2. **Tool schemas** — JSON descriptions of callable tools
3. **Model parameters** model, temperature, max tokens, seed, etc.
---
## The OpenAI Chat Completion Format
## OpenAI-Compatible Chat Format
Didactyl uses the OpenAI-compatible chat completion API format, which is the industry standard. Every LLM request looks like this:
Didactyl uses OpenAI-compatible chat completions.
```json
{
"model": "claude-opus-4.6",
"messages": [
{"role": "system", "content": "You are Didactyl, a sovereign AI agent..."},
{"role": "system", "content": "Admin profile: ..."},
{"role": "user", "content": "Post a note about Bitcoin"},
{"role": "assistant", "content": null, "tool_calls": [...]},
{"role": "tool", "content": "{\"success\":true,...}", "tool_call_id": "..."},
{"role": "assistant", "content": "Done! I posted your note."}
{"role": "system", "content": "..."},
{"role": "user", "content": "..."}
],
"tools": [
{
@@ -37,7 +31,7 @@ Didactyl uses the OpenAI-compatible chat completion API format, which is the ind
"function": {
"name": "nostr_post",
"description": "Publish a Nostr event",
"parameters": { ... }
"parameters": {"type":"object"}
}
}
],
@@ -46,145 +40,122 @@ Didactyl uses the OpenAI-compatible chat completion API format, which is the ind
}
```
### Messages
Messages are an ordered array. Each message has a `role`:
### Message Roles
| Role | Purpose |
|------|---------|
| `system` | Instructions, identity, context data — the LLM reads these but the user didn't write them |
| `user` | What the human (or trigger event) said |
| `assistant` | What the LLM previously said or did (including tool calls) |
| `tool` | Results from tool executions, fed back to the LLM |
The LLM processes all messages in order and generates the next response.
### Tool Schemas
The `tools` array describes every tool the LLM can call. Each tool has a name, description, and JSON Schema for its parameters. The LLM uses these descriptions to decide when and how to call tools.
Tools are not part of the message history — they are a separate capability declaration sent alongside the messages.
### Model Parameters
Temperature, max tokens, and other settings control how the LLM generates its response. These can be set globally or overridden per-skill.
| `system` | Instructions and injected context |
| `user` | Input message or trigger payload |
| `assistant` | Model responses / tool call envelopes |
| `tool` | Tool execution results fed back to model |
---
## How Didactyl Builds Context
## Context Assembly Model
Didactyl assembles context dynamically for each request. The context varies depending on what triggered the request (DM, trigger event) and which skill is active.
Didactyl uses **skill composition by adoption order**.
### Context Assembly Flow
There are no context modes.
### 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).
```mermaid
flowchart TD
INPUT[Input: DM message or trigger event] --> MODE{Skill context_mode?}
MODE -->|inject| INJECT[Start with agent soul/personality]
INJECT --> ADD_ADMIN[Add admin identity + profile]
ADD_ADMIN --> ADD_SKILLS[Add adopted skill instructions]
ADD_SKILLS --> ADD_HISTORY[Add conversation history]
ADD_HISTORY --> ADD_SKILL_INST[Append this skill instructions]
ADD_SKILL_INST --> ADD_USER[Add user message or trigger event]
MODE -->|full| FULL[Start with skill template only]
FULL --> RESOLVE[Resolve template variables via tool calls]
RESOLVE --> ADD_USER2[Add user message or trigger event]
MODE -->|override| OVERRIDE[Replace soul with skill prompt]
OVERRIDE --> ADD_ADMIN2[Add admin identity + profile]
ADD_ADMIN2 --> ADD_SKILLS2[Add adopted skill instructions]
ADD_SKILLS2 --> ADD_HISTORY2[Add conversation history]
ADD_HISTORY2 --> ADD_USER3[Add user message or trigger event]
ADD_USER --> TOOLS[Attach tool schemas]
ADD_USER2 --> TOOLS
ADD_USER3 --> TOOLS
TOOLS --> LLM[Send to LLM]
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]
```
### Context Parts
### Why Order Matters
These are the building blocks that get assembled into the messages array:
- Earlier adopted skills usually establish broad behavior.
- Later adopted skills can refine or narrow behavior.
- If instructions conflict, prompt-order effects apply.
---
## Context Parts
| Part | Source | Description |
|------|--------|-------------|
| **Soul/Personality** | Kind 31120 event | The agent's identity, rules, and behavior instructions |
| **Admin Identity** | Config / kind 3 | Who the admin is, their pubkey |
| **Admin Profile** | Kind 0 event | Admin's display name, about, picture |
| **Admin Relay List** | Kind 10002 event | Admin's preferred relays |
| **Admin Notes** | Kind 1 events | Admin's recent public posts — gives the agent awareness of what the admin is doing |
| **Adopted Skills** | Kind 31123/31124 events | Instructions from other adopted skills |
| **Conversation History** | Kind 4/14 events | Recent DM exchanges between admin and agent |
| **Skill Instructions** | Active skill content | The specific skill being executed |
| **User Message** | DM or trigger event | The input that triggered this execution |
| **Tool Schemas** | Tool registry | JSON descriptions of available tools (sent separately from messages) |
### Template Variables Are Tool Calls
The soul's template system uses `tool:` directives to populate context sections. Each section can specify a tool to call, and the tool's output becomes that section's content:
```yaml
- section: admin_profile
role: system
tool: nostr_admin_profile
skip_if_empty: true
```
This calls `nostr_admin_profile`, gets the result, and inserts it as a system message. Template variables like `{{admin_profile}}` in skill templates work the same way — they resolve to tool calls. There is one unified resolution mechanism: `tools_execute()`.
This means new context sources are added by adding tools, not by modifying the template engine.
| 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 |
---
## Context Modes
## Template Variables Are Tool Calls
Skills control how much of the default context the LLM sees using the `context_mode` field:
Template variables resolve through tool execution.
| Mode | What the LLM sees | Use case |
|------|-------------------|----------|
| `inject` | Everything: soul + admin context + history + skill instructions | Default. Agent stays itself, gains new knowledge. |
| `full` | Only what the skill template provides | Focused tasks: spellcheck, translation. Minimal context = cheaper, faster. |
| `override` | Skill replaces soul, but admin context + history still included | Different personality, same capabilities. |
Example:
Most skills use `inject` (the default). `full` is for specialized tasks where you want to minimize token cost. `override` is rare.
- `{{admin_profile}}` resolves by running `nostr_admin_profile`
- `{{admin_notes}}` resolves by running `nostr_admin_notes`
Unknown variables should resolve to empty values for portability.
---
## Context for Triggered Skills
## Trigger Runtime Parameters
When a trigger fires (Nostr subscription match, webhook POST, cron timer, chain completion), the context is assembled the same way as for a DM — the only difference is the user message:
Execution controls are attached to trigger tags, not skill content:
| Input Source | User Message Content |
|---|---|
| Admin DM | The DM text |
| Nostr subscription trigger | The matching Nostr event as JSON |
| Webhook trigger | The HTTP POST payload as JSON |
| Cron trigger | A synthetic event with timestamp and cron expression |
| Chain trigger | A synthetic event with the source skill's d_tag and output |
- `llm`
- `max_tokens`
- `temperature`
- `seed`
- `tools`
The LLM receives the skill instructions as system context and the triggering event as the user message, then reasons about what to do and which tools to use.
Resolution order for a triggered run:
1. Start with agent defaults
2. Apply trigger tag overrides
3. Execute
4. Restore defaults
---
## Triggered vs Adopted Use
- **Adopted skill (`10123`)**: contributes context/instructions
- **Triggered skill**: contributes context and may supply execution overrides via tags
This separation keeps composition simple while allowing per-trigger runtime control.
---
## Token Budget
Context has a cost — every token sent to the LLM costs money and takes time. Didactyl manages this through:
Context cost is controlled by:
- **Context mode selection** — `full` mode skills send minimal context
- **Skip-if-empty sections** — template sections with no content are omitted
- **History limits** — conversation history is capped (default: 12 turns)
- **Skill content limits** — adopted skill instructions are truncated to prevent context overflow
- **Per-skill model selection** — cheap models for simple tasks, expensive models for complex ones
- Adoption-list ordering and skill count
- Conversation-history limits
- Skill/template truncation limits
- Per-trigger model/runtime parameter choices
The `/api/context/current` and `/api/context/parts` API endpoints let you inspect exactly what context would be sent right now, including character counts and token estimates.
Use runtime context inspection endpoints to see the exact payload before LLM calls.
---
## Related Documentation
- Skills — how to define LLM execution units: [SKILLS.md](SKILLS.md)
- Tools — what the LLM can do: [TOOLS.md](TOOLS.md)
- API — inspect context at runtime: [API.md](API.md)
- Skills spec: [SKILLS.md](SKILLS.md)
- Tool catalog: [TOOLS.md](TOOLS.md)
- API details: [API.md](API.md)

136
docs/GENESIS.md Normal file
View File

@@ -0,0 +1,136 @@
# Didactyl — Genesis Bootstrapping
See also: [CONTEXT.md](CONTEXT.md) · [SKILLS.md](SKILLS.md) · [README.md](../README.md)
## Purpose
`genesis.jsonc` is the first-run bootstrap document for a Didactyl agent.
It defines initial identity, admin policy, startup events, default skill context, and baseline runtime settings so the agent can publish itself onto Nostr.
After bootstrap, the long-term direction is **nsec-only startup** with state recovered from Nostr events.
---
## File Format
`genesis.jsonc` is JSONC (JSON + comments).
Minimum practical sections:
- `key.nsec` (or runtime `--nsec` / `DIDACTYL_NSEC`)
- `admin.pubkey`
- `llm`
- `startup_events` (must include kind `10002` relay tags)
Typical optional sections:
- `dm_protocol`
- `tools`
- `security`
- `admin_context`
- `api`
- `default_skill` (reserved for default-skill publishing workflows)
---
## First-Run Flow
On first run, the agent:
1. Loads `genesis.jsonc`.
2. Derives keys (from `key.nsec` or runtime nsec override).
3. Connects to relay set from startup kind `10002` tags.
4. Publishes/reconciles startup events.
5. Initializes runtime services (DM subscriptions, triggers, API if enabled).
First-run detection is based on querying own kind `10002` relay-list availability.
---
## Subsequent-Run Flow
On subsequent runs, the agent can start with nsec supplied via:
- CLI: `--nsec <nsec_or_hex>`
- Environment: `DIDACTYL_NSEC`
Optional runtime API overrides:
- `--api-port <port>`
- `--api-bind <address>`
Subsequent-run bootstrap-event republishing is skipped when prior kind `10002` state is found.
---
## Interactive Setup (Zero-Argument Startup)
When Didactyl is run with **no arguments**:
```bash
./didactyl
```
it enters an interactive setup wizard instead of immediately trying to load `./genesis.jsonc`.
Wizard entry choices:
- **New agent** — generate/provide identity, configure admin + LLM + relays
- **Existing agent** — provide nsec and recover relay/admin/LLM config from Nostr
- **Load genesis** — load a specified genesis file path
Menu UX conventions:
- Single-letter hotkeys (case-insensitive)
- First-letter menu mnemonics (e.g., `N` for New, `E` for Existing)
- `q` / `x` exits or backs out of menus
Security behavior:
- nsec entry is masked in terminal
- writing genesis with nsec is explicit and warned
- recommended export mode is genesis without nsec plus runtime `--nsec`/`DIDACTYL_NSEC`
---
## Encrypted Config Events
Didactyl exposes config persistence tools for encrypted self-config on Nostr:
- `config_store` — publish encrypted kind `30078` config by `d_tag`
- `config_recall` — query+decrypt kind `30078` config by `d_tag`
Recommended tags:
- `d=llm_config`
- `d=agent_config`
These are encrypted to self with NIP-44.
---
## Relay Bootstrap Strategy
`startup_events` must include kind `10002` relay tags (`["r", "wss://..."]`).
That relay list is used as the initial network attachment for querying existing state and publishing startup events.
---
## Migration Notes (Legacy -> Genesis)
Legacy deployments using `config.jsonc` can migrate by:
1. Copying equivalent sections to `genesis.jsonc`.
2. Ensuring startup kind `10002` relay tags are present.
3. Providing nsec at runtime (`--nsec` or `DIDACTYL_NSEC`) where desired.
4. Treating `context_template.md` / `config.jsonc.example` as legacy references.
---
## Security Notes
- Keep nsec secret.
- Prefer environment or secure credential injection for production nsec handling.
- Avoid publishing plaintext sensitive config; use encrypted `config_store` for long-term state.

View File

@@ -4,11 +4,13 @@ See also: [CONTEXT.md](CONTEXT.md) · [TOOLS.md](TOOLS.md)
## Overview
A skill is a **set of instructions for the LLM** stored as a Nostr event. Skills teach the agent how to accomplish tasks — the LLM reads the instructions, reasons about them, and uses tools to take action.
A skill is a **set of instructions for the LLM** stored as a Nostr event.
Think of it like a woodshop: a **skill** is knowing how to carve — the technique, the judgment, the decision-making. A **tool** is the chisel. The skill never directly uses the chisel without the craftsperson (the LLM) in the loop. If you want a hardcoded program that runs without reasoning, that's a tool or an external program — not a skill.
Skills teach the agent how to accomplish tasks — the LLM reads the instructions, reasons about them, and uses tools to take action.
Skills are portable, shareable, and discoverable — they live on Nostr relays as standard events. Every skill execution involves the LLM.
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.
---
@@ -24,20 +26,14 @@ Skills are portable, shareable, and discoverable — they live on Nostr relays a
## Skill Content
The skill event's `content` field is a JSON object that defines the complete execution specification:
Skill `content` is JSON and should focus on **instructions**, not transport/runtime controls.
```json
{
"kind": 31123,
"content": {
"description": "Check spelling and grammar",
"context_mode": "full",
"llm": "openai/gpt-4o-mini, cheap",
"tools": false,
"max_tokens": 2000,
"temperature": 0.1,
"seed": 42,
"template": "system:\nYou are a spelling and grammar checker.\n\nRules:\n- Fix spelling errors\n- Fix grammar errors\n- Preserve original formatting\n- Ignore: API, JSON, HTTP, nostr, pubkey, npub, nsec, NIP\n- Canadian English preferred\n- Return ONLY the corrected text, no explanations\n\nuser:\n{{message}}"
"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}}"
},
"tags": [
["d", "spellcheck"],
@@ -52,31 +48,176 @@ The skill event's `content` field is a JSON object that defines the complete exe
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `description` | string | — | Human-readable description |
| `context_mode` | string | `inject` | `inject`, `full`, or `override` |
| `llm` | string | `default` | LLM fallback chain |
| `tools` | bool/array | `true` | `false` = no tools, `true` = all tools, array = specific tool names |
| `template` | string | — | Context template (required for `full` mode) |
| `soul` | bool | `true` | Whether to include the agent's soul in context |
| `max_tokens` | int | — | Override max tokens for this skill |
| `temperature` | float | — | Override temperature for this skill |
| `seed` | int | — | Optional deterministic seed (effective only on providers/engines that support seeded sampling) |
| `template` | string | — | Skill instructions/template text (recommended) |
| `base` | bool | `false` | Optional hint that this skill is intended as base/default behavior |
### Determinism Controls
> Execution parameters (`llm`, `max_tokens`, `temperature`, `seed`, `tools`) are defined on **trigger tags**, not in content.
For reproducible outputs, set `temperature` low (typically `0`) and also set `seed` when the selected model backend supports it. If a provider ignores seeds, `temperature` still applies but deterministic replay is not guaranteed.
---
## Composition Model (No Context Modes)
Skills do **not** use `context_mode`.
Context is assembled from kind `10123` adoption list order:
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.
The adoption list itself is the context definition.
- One adopted skill = single-skill behavior.
- Multiple adopted skills = layered behavior in explicit order.
- Reordering `10123` changes precedence naturally.
### Ordering Convention
- 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.
### Template Variables Are Tool Calls
Template variables are tool calls.
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.
---
## Triggered Skills
A triggered skill has a trigger source attached.
Didactyl trigger types:
- `nostr-subscription`
- `webhook`
- `cron`
- `chain`
- `dm`
### 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 |
### 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.
---
## Trigger Types
### `nostr-subscription`
`filter` is a JSON-encoded Nostr subscription filter.
```json
["trigger", "nostr-subscription"],
["filter", "{\"#p\":[\"<admin_pubkey>\"],\"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"]
```
### `cron`
`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"]
```
### `chain`
`filter` is the source skill `d` tag to chain from.
```json
["trigger", "chain"],
["filter", "source-skill-d-tag"],
["llm", "default"],
["enabled", "true"]
```
### `dm`
`filter` is JSON with sender scope:
- `{"from":"admin"}`
- `{"from":"wot"}`
- `{"from":"any"}`
```json
["trigger", "dm"],
["filter", "{\"from\":\"admin\"}"],
["llm", "default"],
["tools", "true"],
["enabled", "true"]
```
---
## Private Skill Encoding (`31124`)
Private skills use NIP-44 encryption on the event `content` field.
Private skills use NIP-44 encryption on event `content`.
Rules for kind `31124`:
- Keep `d` tag exposed so the event remains addressable/replaceable.
- Move all other skill metadata (including non-`d` tags) into the plaintext payload before encryption.
- Encrypt the full payload with NIP-44 and store the ciphertext in event `content`.
- On receive: resolve by `d`, decrypt `content`, then read both content variables and tag values from decrypted payload.
- 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.
### Private Skill Event (on relay)
@@ -96,241 +237,21 @@ Rules for kind `31124`:
{
"content": {
"description": "Monitor mentions and DM summaries",
"context_mode": "inject",
"llm": "openai/gpt-4o-mini, fast",
"tools": ["nostr_query", "nostr_dm"],
"temperature": 0,
"seed": 42,
"template": "When {{triggering_event}} includes Bitcoin or Lightning, summarize and DM admin."
},
"private_tags": [
["scope", "private"],
["trigger", "nostr-subscription"],
["filter", "{\"#p\":[\"<admin_pubkey>\"],\"kinds\":[1]}"],
["llm", "openai/gpt-4o-mini, fast"],
["temperature", "0"],
["seed", "42"],
["tools", "nostr_query,nostr_dm"],
["enabled", "true"]
]
}
```
This mirrors the DM-style encrypted payload pattern (historically associated with NIP-04), but private skills should use NIP-44 ciphertext encoding.
## Context Modes
Skills control how the LLM context window is assembled using the `context_mode` field:
| Mode | Description | Use Case |
|------|-------------|----------|
| `inject` | Skill instructions appended to the agent's default context | Behavioral rules, knowledge additions |
| `full` | Skill provides its own complete context template | Spellcheck, translation, focused analysis |
| `override` | Skill replaces the default system prompt, keeps standard context structure | Different personality, same capabilities |
Default is `inject`. See the agent's soul/personality documentation for details on how context assembly works.
### Template Variables Are Tool Calls
Template variables are tool calls. When the template engine encounters a variable like `{{admin_profile}}`, it runs the corresponding tool and inserts the result into the context that gets sent to the LLM.
This is the unified resolution model: the soul prompt template uses `tool:` section directives, and skill templates use `{{tool_name}}` inline syntax, but both resolve through `tools_execute()`. There is no separate variable-resolver layer — every variable is backed by a tool.
| Variable | Tool Called | Description |
|----------|-----------|-------------|
| `{{soul}}` | *(built-in)* | The agent's identity/system context |
| `{{admin_profile}}` | `nostr_admin_profile` | Admin kind 0 profile |
| `{{admin_notes}}` | `nostr_admin_notes` | Admin recent notes |
| `{{admin_relays}}` | `nostr_admin_relays` | Admin relay list |
| `{{adopted_skills}}` | `adopted_skills` | Other adopted skill instructions |
| `{{dm_history}}` | *(expand directive)* | Recent DM conversation |
| `{{message}}` | *(built-in)* | Current user message |
| `{{triggering_event}}` | `trigger_event` | For triggered skills, the event JSON |
This means new data sources are added by adding tools, not by extending the template engine. If you can call it as a tool, you can use it as a template variable.
---
## LLM Specification with Fallback Chains
Each skill specifies which LLM to use with CSS font-family-style fallbacks. The runtime tries each model in order; if one is unavailable (API error, rate limit, not configured), it falls back to the next.
### Format
```
model-spec := model-ref ["," model-ref]*
model-ref := provider "/" model-name
| category-alias
```
### Examples
| Skill | LLM Spec |
|-------|----------|
| Spelling checker | `openai/gpt-4o-mini, cheap` |
| Complex analysis | `anthropic/claude-sonnet-4-20250514, openai/gpt-4o, smart` |
| Translation | `openai/gpt-4o-mini, fast` |
| Default behavior | `default` |
### Category Aliases
Aliases map to models in the agent's config:
```json
{
"llm": {
"provider": "openai",
"model": "gpt-4o",
"aliases": {
"fast": "openai/gpt-4o-mini",
"cheap": "openai/gpt-4o-mini",
"smart": "anthropic/claude-sonnet-4-20250514"
}
}
}
```
If all specified models fail, the agent's default model is used as a last resort.
```mermaid
sequenceDiagram
participant Skill as Skill Spec
participant Resolver as LLM Resolver
participant API1 as Model 1
participant API2 as Model 2
participant Default as Default Model
Skill->>Resolver: "anthropic/claude-sonnet, openai/gpt-4o-mini, cheap"
Resolver->>API1: try anthropic/claude-sonnet
alt Available
API1-->>Resolver: ✅
else Unavailable
API1-->>Resolver: ❌
Resolver->>API2: try openai/gpt-4o-mini
alt Available
API2-->>Resolver: ✅
else Unavailable
Resolver->>Default: resolve "cheap" alias
end
end
```
---
## Triggered Skills
A triggered skill has a trigger source attached. Didactyl supports `nostr-subscription`, `webhook`, `cron`, and `chain` trigger types.
### Trigger Tags
| Tag | Required | Description |
|---|---|---|
| `trigger` | Yes | Trigger type: `nostr-subscription`, `webhook`, `cron`, or `chain` |
| `filter` | Yes | Type-specific filter — see Trigger Types below |
| `enabled` | No | Whether active (default: `true`) |
> **Note:** The `action` tag is deprecated. All triggered skills are LLM-mediated. If you need a fast hardcoded action without LLM reasoning, use a tool or an external program — not a skill. Skills are instructions for the LLM; the LLM is always in the loop.
### Triggered Skill Execution
When a trigger fires, the skill content is sent to the LLM as instructions along with the triggering event JSON. The LLM reads both, reasons about what to do, and uses tools to take action.
This is the same execution model as DM-invoked skills — the only difference is the input source. A DM-invoked skill receives a user message; a triggered skill receives a triggering event.
```json
{
"kind": 31123,
"content": "You monitor Nostr mentions. When the triggering event mentions Bitcoin or Lightning, summarize it and DM the admin. Otherwise, ignore silently.",
"tags": [
["d", "mention-monitor"],
["trigger", "nostr-subscription"],
["filter", "{\"#p\":[\"<admin_pubkey>\"],\"kinds\":[1]}"],
["enabled", "true"]
]
}
```
A cron-triggered skill:
```json
{
"kind": 31123,
"content": "DM the admin a short heartbeat status message with the current time.",
"tags": [
["d", "heartbeat"],
["trigger", "cron"],
["filter", "*/5 * * * *"],
["enabled", "true"]
]
}
```
### Trigger Types
#### `nostr-subscription`
`filter` is a JSON-encoded Nostr subscription filter.
```json
["trigger", "nostr-subscription"],
["filter", "{\"#p\":[\"<admin_pubkey>\"],\"kinds\":[1]}"]
```
#### `webhook`
`filter` is required for schema compatibility and can be a simple placeholder like `{}`; webhook firing happens via HTTP.
```json
["trigger", "webhook"],
["filter", "{}"]
```
#### `cron`
`filter` is a standard 5-field cron expression: `minute hour day-of-month month day-of-week`.
```json
["trigger", "cron"],
["filter", "0 * * * *"]
```
#### `chain`
`filter` is the source skill `d` tag to chain from.
```json
["trigger", "chain"],
["filter", "source-skill-d-tag"]
```
### Trigger Lifecycle
```mermaid
flowchart TD
subgraph Creation
ADMIN_CMD[Admin: 'Warn me when @jack posts'] --> LLM_REASON[LLM resolves pubkey + builds skill]
LLM_REASON --> SKILL_CREATE[skill_create with trigger tags]
SKILL_CREATE --> PUBLISHED[Skill published to Nostr]
end
subgraph Activation
STARTUP[Didactyl starts up] --> LOAD_SKILLS[Load adopted skills from kind 10123]
LOAD_SKILLS --> FIND_TRIGGERS[Find skills with trigger tags]
FIND_TRIGGERS --> REGISTER[Register trigger by type]
REGISTER --> NOSTR_SUB[nostr-subscription: create Nostr subscriptions]
REGISTER --> CRON_REG[cron: keep expression for poll loop]
REGISTER --> WEBHOOK_REG[webhook: route via /api/trigger/:d_tag]
REGISTER --> CHAIN_REG[chain: wait for source skill completion]
end
subgraph Execution
EVENT_IN[Matching Nostr event] --> LOOKUP[Find associated skill]
WEBHOOK_IN[POST /api/trigger/:d_tag] --> LOOKUP
CRON_TICK[cron poll match] --> LOOKUP
LOOKUP --> RESOLVE[Resolve LLM + assemble context + run with tools]
RESOLVE --> CHAIN_CHECK[Check chain triggers]
CHAIN_CHECK --> CHAIN_FIRE[Fire matching chain triggers]
end
PUBLISHED --> LOAD_SKILLS
```
---
## Execution Flow
@@ -339,30 +260,18 @@ flowchart TD
sequenceDiagram
participant Input as Message/Trigger
participant Dispatch as Dispatcher
participant Skill as Skill Resolver
participant LLM_Res as LLM Resolver
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->>Skill: which skill handles this?
alt No specific skill
Skill-->>Dispatch: use default soul context
Dispatch->>LLM_Res: resolve "default" LLM
else Skill with context_mode=inject
Skill-->>Dispatch: soul + skill instructions
Dispatch->>LLM_Res: resolve skill.llm or "default"
else Skill with context_mode=full
Skill-->>Dispatch: skill template only
Dispatch->>LLM_Res: resolve skill.llm
end
LLM_Res->>LLM_Res: try models in fallback order
LLM_Res-->>Dispatch: resolved model
Dispatch->>Ctx: assemble context per mode
Ctx->>LLM: request with resolved model + context
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
```
@@ -384,21 +293,25 @@ sequenceDiagram
|---|---|
| Skills | Kind 31123/31124 events |
| Adopted skills | Kind 10123 event |
| Trigger definitions | Tags on skill events |
| Trigger definitions + execution params | Tags on skill events |
---
## Future Extensions
## Portability Guidelines
| Extension | Description |
|---|---|
| Skill composition | Pipeline multiple skills |
| Agent-to-agent sharing | Discover and adopt skills across agents |
| Trigger marketplace | Popular triggers rise via adoption count |
To keep skills reusable across agents/clients:
- 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"]`).
A skill should still be useful even when some variables/tools are unavailable.
---
## Related Documentation
- Tool architecture and complete tool catalog: [TOOLS.md](TOOLS.md)
- Project overview and runtime behavior: [README.md](../README.md)
- Context assembly model: [CONTEXT.md](CONTEXT.md)
- Project overview/runtime behavior: [README.md](../README.md)

View File

@@ -0,0 +1,244 @@
# Admin Skills Loading & skill_list Redesign
## Problem Statement
1. **Agent only loads its own skills from nostr** — the admin's public skills (kind `31123`) are never fetched or cached at startup, so the agent cannot discover or adopt them.
2. **`skill_list` only shows adopted skills** — there is no way to see available-but-not-adopted skills, making skill management opaque.
3. **`created_at` displays in scientific notation** — Unix timestamps (~1.7e9) are stored as `double` via cJSON and printed in scientific notation (e.g., `1.7e+09`).
## Current Architecture
```mermaid
flowchart TD
A[main.c startup] --> B[nostr_handler_subscribe_self_skills]
B --> C[Filter: kinds 31123+31124+10123, authors=agent_pubkey]
C --> D[on_self_skill_event callback]
D --> E[self_skill_cache_upsert_event_locked]
E --> F{pubkey == agent?}
F -->|Yes| G[Cache in g_self_skill_events]
F -->|No| H[Reject - BLOCKER]
I[skill_list tool] --> J[nostr_handler_get_self_skill_events_json]
J --> K[Filter: only kind 31123+31124]
K --> L{In adoption list?}
L -->|Yes| M[Include in output]
L -->|No| N[Skip]
```
### Key Files
| File | Role |
|------|------|
| `src/nostr_handler.c:1831` | `nostr_handler_subscribe_self_skills()` — subscribes to agent's own skill events |
| `src/nostr_handler.c:646` | `self_skill_cache_upsert_event_locked()` — caches events, **rejects non-agent pubkeys** |
| `src/nostr_handler.c:2446` | `nostr_handler_get_self_skill_events_json()` — returns cached skill events |
| `src/tools/tool_skill.c:655` | `execute_skill_list()` — lists only adopted skills from cache |
| `src/tools/tool_skill.c:373` | `extract_skill_summary_local()` — builds summary JSON, has `created_at` bug |
| `src/agent.c:1503` | `refresh_adopted_skills_cache_if_needed()` — fetches skill content for context injection |
## Proposed Changes
### 1. Subscribe to Admin's Public Skills on Startup
**File: `src/nostr_handler.c`**
Modify `nostr_handler_subscribe_self_skills()` to also include the admin's pubkey in the authors filter for kind `31123` (public skills only — we should not fetch the admin's private skills or adoption list).
**Approach A — Expand the existing subscription filter:**
Add `g_cfg->admin.pubkey` to the `authors` array in the existing subscription. This is the simplest approach since the subscription already handles kinds `31123`, `31124`, `10123`.
However, this would also fetch the admin's `31124` (private) and `10123` (adoption list) events, which we don't need and can't decrypt. So we need **two subscriptions**:
**Approach B — Add a second subscription (recommended):**
Keep the existing self-skill subscription unchanged. Add a new subscription specifically for the admin's public skills:
- kinds: `[31123]` only
- authors: `[admin_pubkey]`
- limit: 100
This keeps concerns separated and avoids fetching admin private skills or adoption lists.
**New function:** `nostr_handler_subscribe_admin_skills()` or extend the existing function with a second subscription block.
### 2. Expand Skill Cache to Accept Admin Events
**File: `src/nostr_handler.c`**
The core blocker is in `self_skill_cache_upsert_event_locked()` at line 667:
```c
if (!g_cfg || strcmp(pubkey->valuestring, g_cfg->keys.public_key_hex) != 0) {
return;
}
```
**Changes needed:**
1. **Rename the cache** from `g_self_skill_events` to `g_skill_events` (or keep the name but change semantics) to reflect it now holds both agent and admin skills.
2. **Relax the pubkey check** to accept events from either the agent's pubkey OR the admin's pubkey:
```c
int is_agent = (strcmp(pubkey->valuestring, g_cfg->keys.public_key_hex) == 0);
int is_admin = (g_cfg->admin.pubkey[0] != '\0' &&
strcmp(pubkey->valuestring, g_cfg->admin.pubkey) == 0);
if (!is_agent && !is_admin) {
return;
}
```
3. **For admin events, only accept kind `31123`** (public skills) — reject `31124` and `10123` from admin:
```c
if (is_admin && kind_val != 31123) {
return;
}
```
4. **Update `nostr_handler_get_self_skill_events_json()`** to return ALL cached skill events (agent + admin), not just agent's. The function name could be renamed to `nostr_handler_get_skill_events_json()` but to minimize churn, we can keep the name and update the semantics.
5. **Add a new accessor** `nostr_handler_get_all_skill_events_json()` that returns everything, while keeping the existing function for backward compatibility if needed. Or simply update the existing one since `skill_list` is the only consumer that needs the broader view.
### 3. Redesign `skill_list` Output
**File: `src/tools/tool_skill.c`**
Currently `execute_skill_list()` only shows adopted skills. The new design:
#### New Output Structure
```json
{
"success": true,
"summary": [
{
"d_tag": "spellcheck",
"owner": "agent",
"pubkey": "52a3e8...",
"kind": 31123,
"adopted": true
},
{
"d_tag": "translate",
"owner": "admin",
"pubkey": "8fdd1c...",
"kind": 31123,
"adopted": false
},
{
"d_tag": "code-review",
"owner": "admin",
"pubkey": "8fdd1c...",
"kind": 31123,
"adopted": true
}
],
"skills": [
{
"kind": 31123,
"created_at": 1710412800,
"id": "abc123...",
"pubkey": "52a3e8...",
"owner": "agent",
"d_tag": "spellcheck",
"scope": "public",
"description": "Spelling and grammar checker",
"adopted": true,
"content_preview": "...",
"content_length": 450,
"content_truncated": false
}
],
"count": 3,
"adopted_count": 2
}
```
#### Key Changes to `execute_skill_list()`
1. **Remove the adoption-only filter** — iterate ALL cached skill events (agent + admin), not just adopted ones.
2. **Add `adopted` boolean** to each skill summary by checking against the adoption list.
3. **Add `owner` field** — `"agent"` if pubkey matches agent, `"admin"` if pubkey matches admin, otherwise the raw pubkey.
4. **Add `summary` array** at the top — one compact line per skill with just `d_tag`, `owner`, `kind`, and `adopted` status.
5. **Keep `skills` array** with full details on each skill.
6. **Add `adopted_count`** alongside existing `count`.
7. **Optional `filter` parameter** — add an optional `adopted` boolean parameter to filter the list:
- `{"adopted": true}` — show only adopted skills (current behavior)
- `{"adopted": false}` — show only non-adopted skills
- omitted — show all skills
### 4. Fix `created_at` Scientific Notation
**File: `src/tools/tool_skill.c`**
In `extract_skill_summary_local()` at line 389:
```c
if (created_at && cJSON_IsNumber(created_at)) {
cJSON_AddNumberToObject(summary, "created_at", created_at->valuedouble);
}
```
The issue is that `cJSON_AddNumberToObject` stores the value as a `double`, and cJSON's printer uses `%g` format which switches to scientific notation for large numbers.
**Fix:** Cast to `long long` and use `cJSON_AddRaw` or format as a string-encoded integer. The cleanest approach with cJSON:
```c
if (created_at && cJSON_IsNumber(created_at)) {
char ts_buf[32];
snprintf(ts_buf, sizeof(ts_buf), "%lld", (long long)created_at->valuedouble);
cJSON* ts_item = cJSON_CreateRaw(ts_buf);
if (ts_item) {
cJSON_AddItemToObject(summary, "created_at", ts_item);
}
}
```
This outputs the timestamp as a raw integer literal in JSON (e.g., `1710412800`) instead of scientific notation.
**Apply the same fix** to the `kind` field if it also uses `cJSON_AddNumberToObject` — though `kind` values (31123, 31124, 10123) are small enough to not trigger scientific notation.
## Data Flow After Changes
```mermaid
flowchart TD
A[main.c startup] --> B[nostr_handler_subscribe_self_skills]
B --> C[Sub 1: kinds 31123+31124+10123, authors=agent]
B --> D[Sub 2: kinds 31123, authors=admin]
C --> E[on_self_skill_event]
D --> F[on_admin_skill_event - or reuse same callback]
E --> G[skill_cache_upsert_event_locked]
F --> G
G --> H{pubkey == agent OR admin?}
H -->|Yes| I{admin? only kind 31123}
H -->|No| J[Reject]
I -->|OK| K[Cache in g_skill_events]
L[skill_list tool] --> M[get_all_skill_events_json]
M --> N[For each event]
N --> O[Check adoption list]
O --> P[Build summary + detail with adopted flag]
```
## Implementation Order
1. **Fix `created_at` scientific notation** — smallest, isolated change
2. **Expand cache to accept admin pubkey** — modify `self_skill_cache_upsert_event_locked()`
3. **Add admin skill subscription** — new subscription in `nostr_handler_subscribe_self_skills()` or new function
4. **Redesign `skill_list` output** — add summary section, show all skills with adoption status
5. **Update `nostr_handler.h`** — add any new function declarations
6. **Update tool schema** — add `adopted` filter parameter to `skill_list` schema in `tools_schema.c`
7. **Test** — verify with `--test-tool skill_list '{}'`
## Edge Cases
- **Admin pubkey not configured** — skip admin subscription, behave as before
- **Admin and agent publish skill with same d-tag** — both appear in the list, distinguished by `owner` field
- **Admin skill already adopted** — shows `adopted: true` in summary
- **Live updates** — admin skill events received after startup should also be cached (the subscription stays open)

337
plans/agent_clone.md Normal file
View File

@@ -0,0 +1,337 @@
# Agent Cloning
## The Idea
A Didactyl agent can clone itself.
Once launched, a Didactyl agent lives on Nostr. Its identity is a keypair. Its personality is a skill. Its knowledge is in its memories. Its capabilities are adopted skills. Its social graph is a contact list. Its infrastructure preferences are a relay list. All of these are Nostr events — signed, timestamped, published to relays.
There are no files stored on a particular computer. The computer is just the runtime substrate. Destroy it, and the agent survives — start it anywhere else with the same nsec, and it reconstitutes itself from relays.
This architecture has a profound implication: **if all state is events, then cloning is just creating new events.**
The agent generates a fresh keypair. It gathers every event that defines who it is. It re-signs each one with the new key. For encrypted content — memories, private config — it decrypts with its own keys and re-encrypts with the clone's keys. It publishes the clone's events to relays. Then it sends the clone's private key to its administrator via encrypted DM.
The administrator now has a second agent. Identical personality. Identical knowledge. Identical skills. Different identity. Ready to start on any computer, anywhere.
---
## Why This Matters
### Agents as digital organisms
Biological organisms reproduce. The offspring carries the parent's genetic information but is a distinct individual. It can diverge, specialize, adapt to a different environment.
Didactyl cloning works the same way. The clone starts as an exact copy — same personality, same memories, same skills — but from the moment it begins operating, it diverges. Different conversations. Different experiences. Different memories accumulating. Two agents that started identical but become increasingly distinct over time.
This is not redundancy (running the same agent twice for fault tolerance, as described in the [broadcast-debounce model](DECENTRALIZED_DIDACTYL.md)). This is **reproduction** — creating a new individual from an existing one.
### Censorship resistance through multiplication
If an agent can clone itself, censorship becomes a game of whack-a-mole. Shut down one instance, and the agent has already spawned copies on other infrastructure. The agent doesn't need to be running to survive — its state persists on relays. But cloning means it can proactively create backup identities before any threat materializes.
An agent that senses its relays becoming unreliable could clone itself to a different relay set. An agent whose administrator is in a jurisdiction facing regulatory pressure could clone itself with relays in a friendlier jurisdiction. The clone carries everything — personality, knowledge, skills — and can resume operations immediately.
### Specialization through cloning
Clone an agent, then teach the clone different skills. Start with a generalist agent, clone it three times, and specialize each clone:
- **Clone A** adopts financial analysis skills, connects to market data relays
- **Clone B** adopts content creation skills, connects to media-focused relays
- **Clone C** adopts infrastructure management skills, gets local shell access on servers
All three started with the same base personality and knowledge. All three share the same administrator. But each has diverged into a specialist. The administrator now has a team of agents, all spawned from one.
### Agent migration
Cloning is also migration. If you want to move an agent from one LLM provider to another, or from one relay set to another, clone it. The clone gets a fresh identity on the new infrastructure. Once verified, the original can be retired. The agent has "moved" without any downtime or state loss.
### Agent backup
The simplest use case: clone yourself as a backup. The clone sits dormant on relays — its events are published, its identity exists, but no runtime is executing it. If the original is lost, the administrator starts the clone. It picks up where the original left off (minus any state accumulated after the clone was created).
### Dormancy — anywhere, any time
A cloned agent doesn't need to be started immediately. It doesn't need to be started at all. The administrator receives the clone's nsec via encrypted DM and can hold it indefinitely — in a password manager, written on paper, memorized as a mnemonic. The clone's state persists on relays regardless of whether any process is running.
This means the administrator can start the clone **anywhere** — on a laptop, a VPS, a Raspberry Pi, a phone, a friend's server — and **at any time** — tomorrow, next month, five years from now. The agent reconstitutes itself from relays the moment it connects. There is no expiration. There is no server to maintain. The agent simply waits.
This has profound implications for scale. An administrator could have tens or hundreds of dormant clones sitting on relays, each a snapshot of the agent at a different point in time, each with a different specialization, each ready to be activated on demand. The cost of a dormant agent is zero — no compute, no server, no maintenance. It's just events on relays.
Consider the scenarios:
- **Geographic activation** — you're traveling and need an agent running in a specific jurisdiction. You activate a dormant clone on local infrastructure.
- **Temporal activation** — you cloned your agent six months ago before a major project. The project is done. You activate the old clone to compare what the agent knew then versus now.
- **Swarm activation** — you need to process a large task in parallel. You activate 10 dormant clones simultaneously, each on different hardware, each working a different slice of the problem.
The dormant clone is the digital equivalent of a seed vault. Seeds don't consume resources while stored. They contain everything needed to grow a complete organism. They can be planted anywhere there's soil. Dormant Didactyl clones work the same way — they contain everything needed to instantiate a complete agent, they consume nothing while dormant, and they can be activated anywhere there's a computer and an internet connection.
An agent that periodically clones itself is building a library of its own history — a series of snapshots, each one a fully functional agent frozen at a moment in time, any of which can be brought back to life.
---
## What Gets Cloned
A Didactyl agent's identity on Nostr consists of these event kinds:
| Kind | Purpose | Encrypted? |
|------|---------|-----------|
| `0` | Profile metadata (name, about, picture) | No |
| `3` | Contact list (social graph) | No |
| `10002` | Relay list (infrastructure) | No |
| `10123` | Skill adoption list (capabilities) | No |
| `31124` | Private skills (personality, behavior) | No |
| `30078` | Memory (knowledge, context) | Yes — NIP-44 self-encrypted |
| `30078` | Config store (API keys, preferences) | Yes — NIP-44 self-encrypted |
**Public skills** (kind `31123`) are shared resources — they exist independently of any agent and are referenced by address. The clone adopts them by reference, same as the original. They don't need to be copied.
**DM history** is between the original agent and its conversation partners. The clone starts with a clean conversation slate but retains all accumulated knowledge through its cloned memory.
**Ephemeral runtime state** — dedup caches, trigger cooldowns, conversation buffers — is not cloned. This is intentional. The clone is a new process, not a forked process.
---
## The Clone Process
```mermaid
sequenceDiagram
participant Admin
participant Agent as Original Agent
participant Relays
participant Clone as Clone Identity
Admin->>Agent: "Clone yourself"
Agent->>Agent: Generate new keypair
Agent->>Relays: Query all own events
Relays-->>Agent: Events returned
Note over Agent: For each event:
Note over Agent: • Decrypt NIP-44 content (if encrypted)
Note over Agent: • Re-encrypt with clone's keys
Note over Agent: • Re-sign with clone's private key
Agent->>Relays: Publish clone's events
Agent->>Admin: Encrypted DM: clone's nsec + npub
Note over Admin: Later, on any computer:
Admin->>Clone: Start with clone's nsec
Clone->>Relays: Connect, load skills from adoption list
Clone->>Clone: Fully operational
```
The critical insight: **the original agent never stores the clone's private key.** It generates the key, uses it to sign and encrypt the clone's events, sends it to the administrator via encrypted DM, and discards it. After the clone operation, only the administrator possesses the clone's nsec.
---
## The Encrypted State Problem
Most of the agent's state is public — profile, contacts, relay list, skills, adoption list. Cloning these is straightforward: copy the content and tags, re-sign with the new key.
But memory and config are **NIP-44 encrypted to the agent's own public key.** This is the agent's private knowledge — things it has learned, API keys it stores, preferences it has accumulated. Only the agent can read its own memory.
A clone with a different keypair cannot decrypt the original's memory. The clone process must:
1. Decrypt the original's memory with the original's keys
2. Re-encrypt the plaintext with the clone's keys
3. Publish the re-encrypted content as the clone's event
This means the clone operation is a **privileged operation** — it requires the original agent's private key to decrypt its own memories. Only the agent itself can clone itself. No external tool can do it without the agent's cooperation.
This is a feature, not a limitation. The agent is sovereign over its own reproduction.
---
## What a Skill Cannot Do Alone
The initial instinct is to implement cloning as a skill — instructions that teach the agent how to clone itself using existing tools. But the existing tool set has three gaps that make a pure skill approach impossible:
1. **No key generation** — the agent has no tool to generate a new Nostr keypair
2. **No foreign-key signing** — all event publishing uses the agent's own key; there is no way to publish an event signed by a different key
3. **No foreign-key encryption** — NIP-44 encrypt/decrypt tools use the agent's own keypair; re-encrypting memory for a different key is not possible
These are cryptographic operations that require native code. A skill can provide the *judgment* — when to clone, what to tell the administrator, how to handle errors — but the *mechanics* require a dedicated tool.
The natural implementation is a hybrid: a native `agent_clone` tool that handles all cryptographic operations atomically, paired with a skill that teaches the agent when and how to use it.
---
## Security
Cloning creates a new identity with full access to the original's knowledge. This is a sensitive operation.
- **Admin-only** — only the administrator can request a clone
- **nsec delivery via encrypted DM** — the clone's private key is never exposed in plaintext chat, never written to disk, never logged
- **Ephemeral key material** — the clone's private key exists in memory only during the clone operation, then is discarded
- **No reverse access** — after cloning, the original has no access to the clone's private key, and the clone has no access to the original's private key. They are cryptographically independent.
- **No lineage tracking** — the clone has no on-chain reference to its parent. It is a fully independent identity. This is a deliberate choice for privacy — there should be no way for an observer to determine that two agents are related.
---
## Relationship to Decentralized Didactyl
The [Decentralized Didactyl](DECENTRALIZED_DIDACTYL.md) plan describes running multiple agents with different keys to achieve censorship resistance and geographic distribution. That model requires manual setup — the administrator creates each agent independently and configures them with the same skills and soul.
Cloning automates this. Instead of manually setting up three agents across three jurisdictions, the administrator:
1. Sets up one agent
2. Configures it with the desired personality, skills, and knowledge
3. Tells it to clone itself twice
4. Receives three nsecs
5. Starts each clone on a different server
The clones start identical but can then be specialized — different relay lists for different geographies, different triggered skills for different roles, different LLM providers for diversity.
Cloning is the **bootstrap mechanism** for the decentralized agent network.
---
## Implementation Checklist
### Prerequisites
- [ ] Verify `nostr_generate_keypair` links and works — call it from a test harness, confirm it produces valid key pairs
- [ ] Verify `nostr_event_create_signed` or equivalent exists in nostr_core_lib for signing events with an arbitrary private key (not the agent's own)
- [ ] Verify `nostr_nip44_encrypt` / `nostr_nip44_decrypt` work with arbitrary key pairs (not just the agent's own)
### New helper: raw event publishing
- [ ] Add `nostr_handler_publish_raw_event` to `src/nostr_handler.c` — accepts a pre-signed event JSON string and sends it to the relay pool without re-signing
- [ ] Add declaration to `src/nostr_handler.h`
### New tool: `agent_clone`
- [ ] Create `src/tools/tool_clone.c` with `execute_agent_clone`
- [ ] Implement key generation: call `nostr_generate_keypair`, encode nsec/npub via `nostr_key_to_bech32`
- [ ] Implement event gathering: query own events (kinds 0, 3, 10002, 10123, 31124, 30078) via `nostr_handler_query_json`
- [ ] Implement NIP-44 re-encryption: for kind 30078 events, decrypt content with original keys, re-encrypt with clone keys
- [ ] Implement event re-signing: for each gathered event, rebuild with clone pubkey and sign with clone private key
- [ ] Implement kind 10123 fixup: update `a` tag references pointing to own private skills to point to clone's pubkey
- [ ] Implement publishing: send all clone events to relays via `nostr_handler_publish_raw_event`
- [ ] Implement nsec delivery: DM the clone's nsec to the admin via `nostr_handler_send_dm`
- [ ] Add admin-only guard: verify `ctx->template_sender_tier == DIDACTYL_SENDER_ADMIN`
- [ ] Securely zero clone private key from memory after DM is sent
### Tool registration
- [ ] Add `execute_agent_clone` declaration to `src/tools/tools_internal.h`
- [ ] Add dispatch entry in `src/tools/tools_dispatch.c`
- [ ] Add OpenAI function schema in `src/tools/tools_schema.c` — one optional parameter: `name` (string, display name for clone profile)
### Testing
- [ ] Clone an agent, verify clone events appear on relays with correct clone pubkey
- [ ] Verify clone's NIP-44 encrypted events can be decrypted by the clone's keys (not the original's)
- [ ] Start the clone with its nsec, verify it loads skills and memory from relays
- [ ] Verify original agent cannot decrypt clone's memory and vice versa
---
## Code Notes
### Key library functions
From [`nostr_core_lib/nostr_core/nip006.h`](../nostr_core_lib/nostr_core/nip006.h):
```c
// Generate a fresh keypair
int nostr_generate_keypair(unsigned char* private_key, unsigned char* public_key);
```
From `nostr_core_lib/nostr_core/nostr_core.h`:
```c
// NIP-44 encrypt/decrypt with arbitrary keys
int nostr_nip44_encrypt(const unsigned char* sender_private_key,
const unsigned char* recipient_public_key,
const char* plaintext,
char* ciphertext, size_t ciphertext_size);
int nostr_nip44_decrypt(const unsigned char* recipient_private_key,
const unsigned char* sender_public_key,
const char* ciphertext,
char* plaintext, size_t plaintext_size);
// Encode keys to bech32
int nostr_key_to_bech32(const unsigned char* key, const char* hrp, char* output);
```
### Raw event publishing helper
The existing `nostr_handler_publish_kind_event` always signs with the agent's own key. The clone tool needs to publish pre-signed events. A minimal helper:
```c
// nostr_handler.h
int nostr_handler_publish_raw_event(const char* signed_event_json);
// nostr_handler.c
int nostr_handler_publish_raw_event(const char* signed_event_json) {
// Parse the JSON, extract the event
// Send ["EVENT", <event>] to each connected relay via the pool
// Return count of relays that accepted
}
```
### Clone tool skeleton
```c
// src/tools/tool_clone.c
char* execute_agent_clone(tools_context_t* ctx, const char* args_json) {
// 1. Admin-only check
if (ctx->template_sender_tier != DIDACTYL_SENDER_ADMIN)
return json_error("agent_clone is admin-only");
// 2. Generate clone keypair
unsigned char clone_priv[32], clone_pub[32];
nostr_generate_keypair(clone_priv, clone_pub);
// 3. Query own events: kinds 0, 3, 10002, 10123, 31124, 30078
// For each kind, build filter {kinds:[k], authors:[self_pubkey_hex]}
// Call nostr_handler_query_json(filter, timeout)
// 4. For each event:
// - If kind 30078: decrypt content with own keys, re-encrypt with clone keys
// - If kind 10123: update a-tag references to own private skills
// - Rebuild event JSON with clone pubkey
// - Sign with clone private key
// - Publish via nostr_handler_publish_raw_event
// 5. Encode clone nsec
char clone_nsec[128];
nostr_key_to_bech32(clone_priv, "nsec", clone_nsec);
// 6. DM nsec to admin
char dm_msg[512];
snprintf(dm_msg, sizeof(dm_msg),
"Clone created.\nnpub: %s\nnsec: %s\n\nStart with: ./didactyl --nsec %s",
clone_npub, clone_nsec, clone_nsec);
nostr_handler_send_dm(ctx->cfg->admin.pubkey, dm_msg);
// 7. Securely zero clone private key
memset(clone_priv, 0, sizeof(clone_priv));
memset(clone_nsec, 0, sizeof(clone_nsec));
// 8. Return result
return json_success(clone_npub, clone_pubkey_hex, events_cloned, events_published);
}
```
### Tool schema
```json
{
"name": "agent_clone",
"description": "Clone this agent — generate a new identity, copy all state (profile, skills, memory, config), publish clone events to relays, and DM the clone nsec to the administrator. Admin-only.",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Display name for the clone profile. Defaults to original name + ' (clone)'"
}
},
"required": []
}
}
```

251
plans/agent_memory.md Normal file
View File

@@ -0,0 +1,251 @@
# Agent Memory System — Implementation Plan
## Overview
Add short-term and long-term memory to the Didactyl agent, stored as Nostr kind 30078 (addressable app data) events. Both memory types use freeform markdown content, **NIP-44 encrypted** (the agent encrypts to itself for privacy).
- **Short-term memory** — injected into every LLM prompt automatically; the agent's scratchpad for facts, context, and notes that should persist across conversations
- **Long-term memory** — retrieved on demand via a tool call; a larger store for reference material the agent doesn't need in every prompt
Both are global to the agent (not per-user), stored as single replaceable events keyed by `d` tag. Content is NIP-44 encrypted to the agent's own public key, ensuring only the agent can read its own memories.
## Nostr Event Structure
### Short-Term Memory Event
```json
{
"kind": 30078,
"content": "<NIP-44 encrypted markdown>",
"tags": [
["d", "short_term_memory"],
["app", "didactyl"]
]
}
```
Decrypted content example:
```markdown
## Current Context
- Working on project Alpha...
- Admin prefers concise responses...
```
### Long-Term Memory Event
```json
{
"kind": 30078,
"content": "<NIP-44 encrypted markdown>",
"tags": [
["d", "long_term_memory"],
["app", "didactyl"]
]
}
```
Decrypted content example:
```markdown
## Project Notes
### Alpha
- Started 2026-01-15...
## Preferences
- Admin timezone: UTC-3...
```
### Encryption Details
Memory content is NIP-44 encrypted using the agent's own keypair (self-encryption):
- **Encrypt:** `nostr_nip44_encrypt(agent_private_key, agent_public_key, plaintext, ciphertext, size)`
- **Decrypt:** `nostr_nip44_decrypt(agent_private_key, agent_public_key, ciphertext, plaintext, size)`
This follows the existing pattern in `tool_nostr_dm.c` (`execute_nostr_encrypt`/`execute_nostr_decrypt`). The `d` tags remain unencrypted (they must be for addressable event replacement to work), but the actual memory content is private.
## Size Limits
| Memory Type | Max Content Size | Rationale |
|---|---|---|
| Short-term | ~8,000 chars | In every prompt; ~2K tokens budget |
| Long-term | ~32,000 chars | On-demand only; well within 64KB relay limit |
## Architecture
```mermaid
flowchart TD
A[Agent Startup] --> B[Fetch kind 30078 from nostr]
B --> B2[NIP-44 decrypt content with agent keys]
B2 --> C[Cache short_term_memory plaintext in-memory]
B2 --> D[Cache long_term_memory plaintext in-memory]
E[Every Prompt Build] --> F[Soul template section: short_term_memory]
F --> G[tool: memory_short_term_read]
G --> H[Return cached plaintext STM content]
H --> I[Injected as system message in prompt]
J[Agent wants to recall LTM] --> K[Calls my_memory tool]
K --> L{Cached?}
L -->|Yes| M[Return cached plaintext LTM]
L -->|No| N[Query nostr for kind 30078 d=long_term_memory]
N --> N2[NIP-44 decrypt with agent keys]
N2 --> O[Cache plaintext result]
O --> M
P[Agent wants to save memory] --> Q[Calls memory_save tool]
Q --> R{type param}
R -->|short_term| S[NIP-44 encrypt + publish kind 30078]
S --> S2[Update STM cache with plaintext]
R -->|long_term| T[NIP-44 encrypt + publish kind 30078]
T --> T2[Update LTM cache with plaintext]
```
## New Tools
### 1. `memory_save` — Write memory
**Description:** Save content to agent short-term or long-term memory. Publishes as kind 30078 to nostr and updates the local cache.
**Parameters:**
| Param | Type | Required | Description |
|---|---|---|---|
| `type` | string | yes | `"short_term"` or `"long_term"` |
| `content` | string | yes | Markdown content to store |
**Behavior:**
1. Validate `type` is `"short_term"` or `"long_term"`
2. Enforce size limit based on type (on the plaintext, before encryption)
3. Determine d-tag: `"short_term_memory"` or `"long_term_memory"`
4. NIP-44 encrypt the content (agent encrypts to own public key)
5. Publish kind 30078 event with encrypted content, `["d", <d_tag>]` and `["app", "didactyl"]` tags via `nostr_handler_publish_kind_event()`
6. Update the in-memory cache (stores plaintext for fast access)
7. Return success with event_id
### 2. `my_memory` — Read long-term memory
**Description:** Retrieve the agent's long-term memory. Returns cached content or fetches from nostr if not yet loaded.
**Parameters:**
| Param | Type | Required | Description |
|---|---|---|---|
| (none) | — | — | No parameters needed |
**Behavior:**
1. Check if LTM is cached in-memory (plaintext)
2. If cached, return the content
3. If not cached, query nostr for kind 30078 with `d=long_term_memory` authored by self
4. NIP-44 decrypt the content (agent decrypts with own keypair)
5. Cache the plaintext result
6. Return the content (or empty string if no memory exists yet)
### 3. `memory_short_term_read` — Internal tool for prompt injection
**Description:** Internal tool (not exposed to LLM as a callable tool) used by the soul template to inject short-term memory into every prompt.
**Behavior:**
1. Return cached short-term memory content
2. If cache is empty, return empty string (skip_if_empty will omit the section)
## Files to Modify/Create
### New File: `src/tools/tool_memory.c`
Contains implementations for:
- `execute_memory_save()` — handles both STM and LTM writes (NIP-44 encrypts before publishing)
- `execute_my_memory()` — handles LTM reads (NIP-44 decrypts on cache miss)
- `execute_memory_short_term_read()` — internal tool for prompt template injection (returns cached plaintext)
- Static cache variables for STM and LTM **plaintext** content with mutex protection
- `memory_init()` — called at startup to fetch existing memories from nostr and NIP-44 decrypt them into cache
- `memory_cleanup()` — free cached memory on shutdown
- Helper functions for NIP-44 self-encrypt/decrypt using agent's own keypair (pattern from `tool_nostr_dm.c`)
### Modified: `src/tools/tools_internal.h`
Add function declarations:
- `char* execute_memory_save(tools_context_t* ctx, const char* args_json);`
- `char* execute_my_memory(tools_context_t* ctx, const char* args_json);`
- `char* execute_memory_short_term_read(tools_context_t* ctx, const char* args_json);`
- `int memory_init(tools_context_t* ctx);` — fetches from nostr, NIP-44 decrypts, caches plaintext
- `void memory_cleanup(void);`
### Modified: `src/tools/tools_dispatch.c`
Add dispatch entries:
- `"memory_save"``execute_memory_save()`
- `"my_memory"``execute_my_memory()`
- `"memory_short_term_read"``execute_memory_short_term_read()`
### Modified: `src/tools/tools_schema.c`
Add OpenAI function schemas for:
- `memory_save` — exposed to LLM (type + content params)
- `my_memory` — exposed to LLM (no params)
- `memory_short_term_read` is NOT added to schema (internal only, called by template)
### Modified: `src/agent.c`
- Call `memory_init()` during `agent_init()` to load existing memories from nostr on startup
- Call `memory_cleanup()` during `agent_cleanup()`
- Add `"short_term_memory"` to context section detection in `detect_context_section()`
### Modified: Soul template in `config.jsonc.example`
Add a new template section for short-term memory injection:
```yaml
- section: short_term_memory
role: system
tool: memory_short_term_read
skip_if_empty: true
```
This goes after the existing context sections (admin identity, profile, etc.) and before the DM history expand section.
### Modified: `Makefile`
Add `src/tools/tool_memory.c` to the build.
## Startup Flow
1. `agent_init()` calls `memory_init()`
2. `memory_init()` queries nostr for kind 30078 events authored by self with d-tags `short_term_memory` and `long_term_memory`
3. For each event found, NIP-44 decrypt the content using the agent's own keypair
4. Decrypted plaintext results are cached in static variables protected by mutex
5. If no events found, caches remain empty (agent starts with blank memory)
6. If decryption fails (e.g., key mismatch from a previous agent identity), log a warning and start with blank memory
## Prompt Injection Flow (Short-Term Memory)
1. `prompt_template_build_messages()` encounters the `short_term_memory` section
2. Section has `tool: memory_short_term_read` — calls `execute_memory_short_term_read()`
3. Tool returns cached STM content as the `content` field
4. If empty and `skip_if_empty: true`, section is omitted from prompt
5. If non-empty, injected as a system message like:
```
## Short-Term Memory
<agent's markdown notes here>
```
## Cache Design
```c
// In tool_memory.c
static char* g_short_term_memory = NULL; // cached STM content
static char* g_long_term_memory = NULL; // cached LTM content
static int g_stm_loaded = 0; // whether STM has been fetched
static int g_ltm_loaded = 0; // whether LTM has been fetched
static pthread_mutex_t g_memory_mutex = PTHREAD_MUTEX_INITIALIZER;
#define MEMORY_STM_MAX_CHARS 8000
#define MEMORY_LTM_MAX_CHARS 32000
```
## Implementation Order
1. Create `src/tools/tool_memory.c` with cache, init, cleanup, and all three tool functions
2. Add declarations to `tools_internal.h`
3. Add dispatch entries to `tools_dispatch.c`
4. Add schemas for `memory_save` and `my_memory` to `tools_schema.c`
5. Wire up `memory_init()`/`memory_cleanup()` in `agent.c`
6. Add `short_term_memory` template section to soul in `config.jsonc.example`
7. Update `Makefile` build
8. Update soul template in system prompt to mention memory capabilities
9. Test: save STM → verify prompt injection; save LTM → verify recall via my_memory

View File

@@ -0,0 +1,153 @@
# Plan: Eliminate Soul — Everything Is a Skill
## Summary
Remove the concept of a "soul" (kind `31120`) as a special, privileged entity. The agent's base personality, instructions, and context assembly template become a regular private skill (kind `31124`) that is adopted like any other skill. This simplifies the architecture: there is only one concept — **skills** — and the adoption list (`10123`) determines what the agent knows and how it behaves.
## Architecture Decision
- Kind `31120` is eliminated entirely.
- What was the "soul" becomes a private skill (kind `31124`) with a conventional d-tag (e.g., `didactyl-default`).
- The `---template---` mechanism stays — any skill can contain it.
- The `genesis.jsonc` `default_skill` field defines this skill's content for first-run publishing.
- On subsequent runs, the agent fetches its adopted skills from Nostr relays.
## Startup Flow
```
First Run (genesis.jsonc present):
1. Read genesis.jsonc
2. Publish default_skill as kind 31124 (private skill) to relays
3. Publish kind 10123 adoption list referencing the default skill
4. Load default_skill content into g_system_context
5. Continue normal startup
Subsequent Run (nsec only):
1. Detect not-first-run via kind 10002 presence
2. Query own kind 10123 adoption list from relays
3. Fetch first adopted skill content from relays
4. Load that content into g_system_context
5. Continue normal startup
```
## genesis.jsonc Schema
The `default_skill` field replaces the old kind `31120` startup event:
```jsonc
{
"key": { "nsec": "nsec1..." },
"admin": { "pubkey": "npub1..." },
"llm": { ... },
"api": { ... },
// Default skill — published as kind 31124 on first run
// and adopted into the agent's 10123 list.
"default_skill": {
"d_tag": "didactyl-default",
"kind": 31124,
"content": "# Didactyl Agent\n\nYou are Didactyl...\n\n---template---\n\n- section: admin_identity\n role: system\n tool: admin_identity\n ...",
"tags": [
["app", "didactyl"],
["scope", "private"]
]
}
}
```
## Code Changes
### src/nostr_handler.c
- Remove the kind `31120` scan in `nostr_handler_reconcile_startup_events()` (lines 2487-2493).
- Add logic to load `g_system_context` from the `default_skill` config field on first run.
- On subsequent run, query own `10123` adoption list, resolve the first adopted skill address, fetch its content, and set `g_system_context`.
### src/config.c / src/config.h
- Add `default_skill` parsing to `config_load()`.
- Add a `default_skill_t` struct to `didactyl_config_t` with fields: `d_tag`, `kind`, `content`, `tags_json`.
- Remove any special handling of kind `31120` in startup event parsing.
### src/main.c
- On first run: publish the `default_skill` as a kind `31124` event and publish/update the `10123` adoption list to include it.
- On subsequent run: the adoption-list-driven fetch provides the system context.
- Remove the kind `31120` fallback in system context extraction.
### src/agent.c
- `g_system_context` continues to work the same way — it's just sourced from a skill instead of a soul.
- The trigger execution path (`agent_on_trigger`) prepends `g_system_context` unchanged.
- The WoT/stranger chat path uses `g_system_context` unchanged.
- The non-template fallback path uses `g_system_context` unchanged.
### src/prompt_template.c
- Rename `soul_content` parameter to `skill_content` in `prompt_template_parse()`.
- Rename `personality` field to `base_instructions` or similar.
- No functional changes — the `---template---` split mechanism is unchanged.
### config.jsonc
- Replace the kind `31120` startup event with a kind `31124` startup event using the same content.
- Update the d-tag from `soul` to `didactyl-default`.
### genesis.jsonc
- Populate the `default_skill` field with the current soul content (personality + template sections).
## Documentation Changes
### README.md
- Remove all references to "Soul", "soul", kind `31120`.
- Replace "Soul/personality" with "Default skill" or "Base skill".
- Update the Didactyl Kinds table: remove `31120` row.
- Update the Roadmap table: remove "Soul/personality | Kind 31120" row.
- Update context model description: "assembled from adopted skill templates" not "soul template".
### docs/SKILLS.md
- Remove `{{soul}}` template variable.
- Remove `soul` content field from skill schema.
- Remove any language about soul being special or separate from skills.
- Document that any skill can contain `---template---` sections.
### docs/CONTEXT.md
- Remove soul-specific assembly language.
- Context is assembled from adopted skills in adoption list order.
### docs/GENESIS.md
- Update to reference `default_skill` instead of kind `31120` soul event.
- Update first-run behavior description.
### docs/API.md
- Remove `/api/events/soul` endpoint.
- Rename `system_prompt` context part to `default_skill` or `base_instructions`.
## What Does NOT Change
- The `---template---` mechanism works identically — it just lives in a skill.
- Kind `10123` adoption list still drives context composition order.
- Template variable resolution via tools is unchanged.
- Trigger execution flow is unchanged (uses `g_system_context`).
- Encrypted config tools (`config_store`/`config_recall`) are unchanged.
- The genesis/nsec startup detection is unchanged.
## Dependency Order
1. Define `default_skill` schema in config structs and genesis.jsonc
2. Update config parser to read `default_skill`
3. Update nostr_handler to source system context from default_skill (first run) or adopted skill (subsequent run)
4. Update main.c first-run path to publish default_skill as 31124 and update 10123
5. Remove all kind 31120 references from code
6. Rename soul terminology in prompt_template.c
7. Update config.jsonc startup events
8. Update all documentation
9. Build and test

View File

@@ -0,0 +1,429 @@
# Didactyl — Interactive Guided Setup Mode
## Overview
When `./didactyl` is run with **no command-line arguments**, the agent enters an interactive guided setup wizard on the terminal. This replaces the current behavior of silently trying to load `./genesis.jsonc` and failing.
The wizard first asks whether the user is creating a new agent or starting an existing one, then branches accordingly.
---
## Trigger Condition
```
if (argc == 1) -> enter interactive setup mode
```
Any argument at all (`--config`, `--nsec`, `--help`, etc.) bypasses the wizard and uses the existing startup path. The zero-argument case is the only entry point.
---
## TUI Menu Convention
All menus use **single-letter hotkeys** (case-insensitive). The hotkey letter is rendered **underlined** in the terminal using ANSI escape `\e[4m` (underline on) and `\e[0m` (reset). For example, `[N]ew` displays with the N underlined.
Global shortcuts:
- `q` or `x` -- quit/back out of any menu
- Ctrl+C -- clean exit (restore terminal settings)
Input is read with `fgets()` and matched on the first non-whitespace character.
---
## Wizard Flow
```mermaid
flowchart TD
A[./didactyl with no args] --> B[Welcome screen]
B --> C{New or Existing?}
C -->|New agent| D[New Agent Flow]
C -->|Existing agent| E[Existing Agent Flow]
C -->|Load genesis| F[Load genesis.jsonc and boot]
C -->|Quit| Z[Exit]
D --> D1[Step: Generate or provide nsec]
D1 --> D2[Check if pubkey exists on Nostr]
D2 -->|Exists - warn| D3{Continue or abort?}
D2 -->|Fresh| D4[Step: Admin npub]
D3 -->|Continue| D4
D3 -->|Abort| Z
D4 --> D5[Step: LLM provider + test]
D5 --> D6[Step: Relay config]
D6 --> D7[Step: Review and boot/export]
D7 --> BOOT[Normal startup]
E --> E1[Step: Provide nsec]
E1 --> E2[Connect to bootstrap relays]
E2 --> E3[Recover config from Nostr]
E3 -->|Config found| E4{Review recovered config}
E3 -->|Config missing| E5[Prompt for missing config]
E4 --> BOOT
E5 --> D4
```
---
## Step Details
### Welcome Screen
**Prompt:**
```
=============================================
Didactyl v0.0.71 -- Interactive Setup
=============================================
[N]ew agent -- create a fresh Nostr identity
[E]xisting -- start an agent you have already set up
[L]oad -- boot from a genesis.jsonc file
[Q]uit
>
```
---
### Path A: New Agent
#### A1. Identity
**Prompt:**
```
New Agent Setup -- Identity
[G]enerate a new Nostr keypair
[P]rovide an existing nsec
[B]ack
>
```
**Option G -- Generate new keypair:**
- Call `nostr_generate_keypair()` from `nip006.h`
- Call `nostr_key_to_bech32()` from `nip019.h` with `hrp="nsec"` and `hrp="npub"` to display bech32 keys
- Display the generated nsec and npub
- **Critical warning**: "Save your nsec securely. It will NOT be stored unless you choose to write a genesis file."
- Prompt user to confirm they have saved the nsec before proceeding
**Option P -- Provide existing nsec:**
- Accept nsec1... bech32 or 64-char hex
- Mask input with `termios` echo disable
- Validate using existing `derive_keys_from_nsec()` logic
- Display derived npub for confirmation
**After key derivation -- Existing identity check:**
- Connect to default bootstrap relays (damus, primal, nos.lol)
- Query kind 10002 for the derived pubkey
- If found: warn the user that this identity already exists on Nostr
- "This pubkey already has a kind 10002 relay list on Nostr."
- "Running first-run genesis will overwrite existing profile/relay/skill events."
- "[C]ontinue anyway or [A]bort?"
- If not found: inform user this is a fresh identity, proceed
#### A2. Administrator
**Prompt:**
```
New Agent Setup -- Administrator
Enter the admin's Nostr public key (npub1... or hex):
>
```
- Validate using `decode_pubkey_hex_or_npub()`
- Display the decoded hex for confirmation
- This is required -- loop until valid input
- `b` to go back
#### A3. LLM Provider
**Prompt:**
```
New Agent Setup -- LLM Provider
Didactyl needs an OpenAI-compatible LLM API.
Base URL [https://api.openai.com/v1]:
API Key:
Model [gpt-4o-mini]:
Max Tokens [512]:
Temperature [0.7]:
```
- Show defaults in brackets, accept Enter for default
- After collecting all fields, make a **test API call**:
- Send a minimal chat completion request
- Display result: "LLM test: OK (model responded)" or "LLM test: FAILED (HTTP 401 -- check API key)"
- On failure:
```
LLM test: FAILED (HTTP 401 -- check API key)
[R]e-enter LLM settings
[S]kip test and continue anyway
[Q]uit
>
```
#### A4. Relay Configuration
**Prompt:**
```
New Agent Setup -- Relay Configuration
Current relays:
1. wss://relay.damus.io
2. wss://nos.lol
3. wss://relay.primal.net
[A]dd a relay
[R]emove a relay (by number)
[D]one -- use this list
[B]ack
>
```
- Start with the 3 default bootstrap relays
- Allow adding custom relay URLs (validate wss:// or ws:// prefix)
- Allow removing by number (prompt: "Remove which number?")
- Minimum 1 relay required
- Display updated list after each change
#### A5. Review and Confirm
**Prompt:**
```
New Agent Setup -- Review
Identity: npub1...
Admin: npub1...
LLM: claude-haiku-4.5 @ https://api.ppq.ai
Relays: 3 configured
DM Protocol: nip04
[B]oot the agent now
[W]rite genesis.jsonc (without nsec) and boot
[I]nclude nsec in genesis.jsonc and boot (security risk!)
[E]xport genesis.jsonc (without nsec) and exit
[S]tart over
[Q]uit
>
```
- **B** -- populate `didactyl_config_t` in memory and proceed to normal startup
- **W** -- write genesis.jsonc without the nsec field, then boot
- **I** -- write genesis.jsonc WITH nsec (with explicit warning), then boot
- **E** -- write file and exit so user can review
- **S** -- restart wizard from welcome screen
- **Q** -- exit
---
### Path B: Existing Agent
#### B1. Provide nsec
**Prompt:**
```
Existing Agent -- Identity
Enter your agent's nsec (nsec1... or hex):
>
```
- Mask input with `termios` echo disable
- Validate and derive pubkey
- Display npub for confirmation
#### B2. Recover Config from Nostr
After key derivation:
1. Connect to default bootstrap relays
2. Query kind 10002 for the pubkey's relay list
3. If relay list found: expand relay pool with discovered relays
4. Query encrypted kind 30078 events for `d=llm_config` and `d=agent_config`
5. Decrypt and apply recovered config (reuses existing `recover_missing_runtime_config_from_nostr()` logic)
**Display recovery status:**
```
Existing Agent -- Config Recovery
Relay list (kind 10002): FOUND (6 relays)
LLM config: FOUND (claude-haiku-4.5 @ ppq.ai)
Admin config: FOUND (npub1...)
[B]oot with recovered config
[E]dit settings before booting
[Q]uit
>
```
If any required config is missing:
```
Existing Agent -- Config Recovery
Relay list (kind 10002): FOUND (6 relays)
LLM config: NOT FOUND
Admin config: NOT FOUND
Some required config was not found on Nostr.
Entering guided setup for missing fields...
```
Then jump to the appropriate new-agent steps (A2/A3) for the missing pieces only.
---
### Path C: Load Genesis File
**Prompt:**
```
Load Genesis File
Path [./genesis.jsonc]:
>
```
- Accept file path, default to `./genesis.jsonc`
- Load with `config_load()`
- If nsec is missing from the file, prompt for it
- Proceed to normal startup
---
## Implementation Architecture
### New Source File: `src/setup_wizard.c` / `src/setup_wizard.h`
Keep the wizard logic isolated from `main.c`. The interface:
```c
// Returns 0 on success (config populated, ready to boot)
// Returns -1 on abort/error
// Returns 1 on "wrote genesis and exit" (no boot)
int setup_wizard_run(didactyl_config_t* config, char* genesis_path_out, size_t path_size);
```
### Changes to `src/main.c`
In `main()`, before the existing argument parsing:
```c
if (argc == 1) {
if (!isatty(STDIN_FILENO)) {
print_usage(argv[0]);
return 1;
}
debug_init(DEBUG_LEVEL_INFO);
if (nostr_init() != NOSTR_SUCCESS) {
fprintf(stderr, "Failed to initialize nostr core\n");
return 1;
}
didactyl_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
char genesis_path[256] = {0};
int wizard_rc = setup_wizard_run(&cfg, genesis_path, sizeof(genesis_path));
if (wizard_rc < 0) {
nostr_cleanup();
return 1; // user aborted
}
if (wizard_rc == 1) {
nostr_cleanup();
return 0; // wrote genesis and exit
}
// Continue with normal startup using populated cfg
// Jump past config_load() into the startup checklist
goto startup_with_config;
}
```
### Changes to `nostr_core_lib`
**No new functions needed.** The existing API surface is sufficient:
- `nostr_generate_keypair()` -- generate private/public key pair
- `nostr_key_to_bech32(key, "nsec", output)` -- encode private key as nsec1...
- `nostr_key_to_bech32(key, "npub", output)` -- encode public key as npub1...
- `nostr_decode_nsec()` / `nostr_decode_npub()` -- decode bech32 inputs
### LLM Test Call
Reuse the existing `llm.c` infrastructure:
- Temporarily initialize `llm_init()` with the test config
- Make a single completion call with a trivial prompt
- Check for HTTP success and valid response
- Clean up with `llm_cleanup()`
### Existing Identity Check / Config Recovery
For both new and existing agent paths:
- Initialize relay pool with bootstrap relays
- Query kind 10002 for the derived pubkey
- For existing agents: also query kind 30078 encrypted config events
- Reuse `is_first_run_from_kind10002()` and `recover_missing_runtime_config_from_nostr()` logic
### Genesis File Writer
A helper function that serializes the collected config to JSONC format:
```c
int setup_wizard_write_genesis(const didactyl_config_t* config,
const char* path,
int include_nsec);
```
This writes a human-readable JSONC file with comments, matching the existing `genesis.jsonc` format.
---
## Terminal I/O Considerations
- Use `fgets()` for line input (not `scanf`)
- Mask nsec input with `termios` echo disable on Linux
- Handle Ctrl+C gracefully (restore terminal settings via `atexit` handler)
- Use ANSI underline (`\e[4m`) for hotkey letters in menu options
- Use ANSI colors sparingly for emphasis (warnings in yellow/red)
- All prompts go to stderr, so stdout can be piped if needed
---
## Security Considerations
- Generated nsec is held in memory only unless user explicitly chooses to write genesis with nsec
- nsec input is masked (echo disabled) during entry
- If writing genesis with nsec, display a clear warning about the security implications
- The nsec-less genesis file is the recommended default
- After writing, suggest: "For subsequent runs, use: `./didactyl --config genesis.jsonc --nsec <your_nsec>`"
- Or: "Set DIDACTYL_NSEC environment variable for convenience"
---
## Files to Create/Modify
| File | Action | Description |
|------|--------|-------------|
| `src/setup_wizard.h` | Create | Wizard public interface |
| `src/setup_wizard.c` | Create | Interactive wizard implementation |
| `src/main.c` | Modify | Add `argc == 1` check to enter wizard before arg parsing |
| `Makefile` | Modify | Add `setup_wizard.o` to build |
| `docs/GENESIS.md` | Modify | Document the interactive setup mode |
| `README.md` | Modify | Add guided setup to usage section |
---
## Edge Cases
1. **Piped/non-TTY stdin**: Detect with `isatty(STDIN_FILENO)`. If not a TTY, print usage and exit instead of entering wizard.
2. **Ctrl+C during setup**: Signal handler restores terminal settings and exits cleanly.
3. **Genesis file already exists at write path**: Prompt "[O]verwrite or [C]hoose different path?" before overwriting.
4. **LLM test timeout**: Set a reasonable timeout (10s) and allow skipping.
5. **No relay connectivity during identity check**: Warn but allow proceeding -- the check is best-effort.
6. **Existing agent with no config on Nostr**: Seamlessly transition to new-agent steps for the missing pieces.
7. **Back navigation**: Each step supports `b` to go back to the previous step.

View File

@@ -0,0 +1,315 @@
# Skill-Driven Architecture — Implementation Plan
## Summary
Overhaul Didactyl so that **skills are the universal unit of behavior** and **Nostr is the source of truth for all agent state**. The agent boots from a single nsec; everything else — context template, LLM config, relay list, personality — lives on Nostr as skills or encrypted events.
---
## Architecture Decisions (Finalized)
| Decision | Detail |
|----------|--------|
| Genesis config | `genesis.jsonc` is consumed on first run only; can be deleted afterward |
| Runtime identity seed | nsec (via CLI flag or systemd credential) is the only required input after genesis |
| Context template | Becomes the `didactyl_default` skill — first entry in kind 10123 adoption list |
| Context modes | Removed. `inject`/`full`/`override` replaced by adoption-list-order composition |
| Execution params | `llm`, `temperature`, `max_tokens`, `seed`, `tools` move from skill content to trigger tags |
| DM trigger type | New trigger type `dm` — makes DM handling consistent with all other triggers |
| LLM config storage | Kind 30078 `d=llm_config`, NIP-44 encrypted to self, stored on Nostr |
| Admin config storage | Admin pubkey stored as tag on soul event or dedicated config event on Nostr |
| Bootstrap relays | Listed in `genesis.jsonc`; agent adopts admin relay list on first run |
| Skill portability | Skills are general Nostr events; implementation-specific variables resolve to empty |
---
## Phase 1 — Specification Updates
Update the authoritative documentation to reflect all decisions before writing code.
### 1.1 Update docs/SKILLS.md
- Remove `llm`, `temperature`, `max_tokens`, `seed`, `tools` from the Content Fields table
- Add execution-parameter tags to the Trigger Tags table: `llm`, `temperature`, `max_tokens`, `seed`, `tools`
- Add `dm` trigger type with filter format `{"from":"admin"}`, `{"from":"wot"}`, `{"from":"any"}`
- Update all examples to show execution params as tags, not content fields
- Update the private skill decrypted payload example accordingly
- Remove the LLM Specification section header (move fallback-chain docs into trigger tag reference)
- Update the Execution Flow mermaid diagram
### 1.2 Update docs/CONTEXT.md
- Remove the Context Modes section entirely (inject/full/override)
- Remove the context_mode branching from the Context Assembly Flow mermaid diagram
- Replace with adoption-list-order composition model
- Update Context Parts table to reference skills instead of soul event
- Update Token Budget section to remove context_mode references
### 1.3 Write docs/GENESIS.md (new)
- Document the genesis.jsonc format and purpose
- Document the first-run flow: genesis consumed, events published, LLM config encrypted
- Document the subsequent-run flow: nsec only, everything fetched from Nostr
- Document bootstrap relay strategy
---
## Phase 2 — Genesis Startup Flow
### 2.1 Define genesis.jsonc schema
```jsonc
{
"key": { "nsec": "nsec1..." },
"admin": { "pubkey": "npub1..." },
"dm_protocol": "nip04",
"llm": {
"provider": "...",
"api_key": "...",
"model": "...",
"base_url": "...",
"max_tokens": 512,
"temperature": 0.7
},
"api": {
"enabled": true,
"port": 8484,
"bind_address": "127.0.0.1"
},
"bootstrap_relays": [
"wss://relay.damus.io",
"wss://nos.lol"
],
"startup_events": [
// Kind 0 profile, kind 10002 relay list, kind 10050 DM relays,
// kind 3 contact list, kind 31123/31124 skills,
// kind 10123 adoption list
],
"default_skill": {
// The didactyl_default skill content — soul + context template
}
}
```
### 2.2 Implement first-run detection
- On startup, check if the agent has published a kind 10002 event (relay list) on bootstrap relays
- If no kind 10002 found: this is a first run — consume genesis.jsonc
- If kind 10002 found: this is a subsequent run — fetch everything from Nostr
### 2.3 Implement genesis event publishing
- Connect to bootstrap relays
- Fetch admin kind 10002 (relay list) and adopt those relays
- Publish all startup_events from genesis.jsonc
- Publish default_skill as kind 31123 with `["d", "didactyl_default"]`
- Publish kind 10123 adoption list with didactyl_default as first entry
- Encrypt and publish LLM config as kind 30078 `d=llm_config`
- Encrypt and publish admin pubkey + dm_protocol as kind 30078 `d=agent_config`
### 2.4 Implement nsec-only startup
- Accept nsec via `--nsec` CLI flag or `DIDACTYL_NSEC` environment variable
- Accept optional `--api-port` and `--api-bind` for local API config
- Derive pubkey from nsec
- Connect to hardcoded bootstrap relays (compiled-in fallback list)
- Fetch own kind 10002 → connect to own relays
- Fetch kind 30078 `d=llm_config` → decrypt → initialize LLM client
- Fetch kind 30078 `d=agent_config` → decrypt → get admin pubkey, dm_protocol
- Fetch kind 10123 → get adoption list
- Fetch adopted skills → build context template
- Proceed to normal operation
---
## Phase 3 — Adoption-List-Order Context Assembly
### 3.1 Refactor context building in agent.c
Current flow (agent_build_admin_messages_json):
- If prompt template exists: use prompt_template_build_messages
- Else: hardcoded context assembly (append_admin_identity_context, append_startup_events_context, append_adopted_skills_context, etc.)
New flow:
- Fetch kind 10123 adoption list
- For each adopted skill in order:
- Fetch skill content
- If skill has a template: resolve template variables via tools_execute
- Append resolved content as system message(s)
- Append DM history (if dm trigger)
- Append user message / triggering event
### 3.2 Unify template variable resolution
Current state: two template formats exist:
- YAML section format with `tool:` directives (prompt_template.c)
- `{{variable}}` inline syntax (documented in SKILLS.md but not fully implemented)
Target: single `{{variable}}` format that resolves through tools_execute.
- Implement `{{variable}}` resolution in a new or updated template engine
- Each `{{variable_name}}` calls `tools_execute(ctx, variable_name, "{}")`
- Unknown variables resolve to empty string
- The YAML section format becomes legacy (still supported for backward compat during transition)
### 3.3 Remove hardcoded context assembly
- Remove append_admin_identity_context, append_startup_events_context, append_adopted_skills_context from agent.c
- These become unnecessary because the didactyl_default skill template handles all of it via {{variable}} resolution
- Keep the functions available as tools so templates can call them
---
## Phase 4 — Execution Params on Trigger Tags
### 4.1 Update trigger_manager to read execution params from tags
Current trigger registration reads: `trigger`, `filter`, `action`, `enabled`
Add reading of: `llm`, `temperature`, `max_tokens`, `seed`, `tools`
Store these on the trigger_entry_t struct.
### 4.2 Apply execution params at invocation time
When a trigger fires:
- If `llm` tag present: temporarily override the LLM model for this execution
- If `temperature` tag present: temporarily override temperature
- If `max_tokens` tag present: temporarily override max_tokens
- If `tools` tag present: filter available tools for this execution
- After execution: restore defaults
Use the existing model_set/model_get pattern (llm_set_config/llm_get_config) for temporary overrides.
### 4.3 Remove execution params from skill content parsing
- Stop reading `llm`, `temperature`, `max_tokens`, `seed`, `tools` from skill content JSON
- These fields in content are ignored (backward compat: warn if present)
---
## Phase 5 — DM Trigger Type
### 5.1 Add dm trigger type to trigger_manager
- New trigger type: `TRIGGER_TYPE_DM`
- Filter format: `{"from":"admin"}`, `{"from":"wot"}`, `{"from":"any"}`
- Registration: when loading skills with `["trigger", "dm"]`, register as DM trigger
### 5.2 Refactor agent_on_message to use trigger dispatch
Current flow:
- agent_on_message receives DM
- Checks sender tier
- Builds context directly
- Calls LLM
New flow:
- agent_on_message receives DM
- Checks sender tier
- Finds matching DM trigger(s) from registered triggers
- For each matching trigger: execute via the standard trigger execution path
- If no DM trigger matches: fall back to default behavior (or reject)
### 5.3 didactyl_default gets a dm trigger
The default skill in genesis.jsonc includes:
```json
["trigger", "dm"],
["filter", "{\"from\":\"admin\"}"],
["llm", "default"],
["tools", "true"],
["enabled", "true"]
```
This makes the normal admin DM conversation a triggered skill execution, consistent with everything else.
---
## Phase 6 — Encrypted Config Storage
### 6.1 Implement config_store tool
New tool: `config_store` — encrypts and publishes agent config to Nostr
- Kind 30078 with configurable d-tag
- NIP-44 encrypted to self (same pattern as memory tool)
- Used for: `d=llm_config`, `d=agent_config`
### 6.2 Implement config_recall tool
New tool: `config_recall` — fetches and decrypts agent config from Nostr
- Query kind 30078 by d-tag and own pubkey
- NIP-44 decrypt
- Return JSON content
### 6.3 Use config tools during startup
- During genesis: call config_store for llm_config and agent_config
- During nsec-only startup: call config_recall to recover LLM and agent config
- These tools are also available to the LLM for runtime config changes
---
## Phase 7 — Documentation Cleanup
### 7.1 Update README.md
- Update startup instructions to reflect genesis.jsonc
- Update architecture overview
- Remove references to config.jsonc as the primary config
### 7.2 Deprecate config.jsonc
- Keep config.jsonc.example as reference but mark as legacy
- Document migration path from config.jsonc to genesis.jsonc
### 7.3 Update context_template.md
- Mark as legacy/deprecated
- Point to didactyl_default skill as the replacement
---
## File Change Summary
| File | Change Type | Description |
|------|------------|-------------|
| `docs/SKILLS.md` | Modify | Execution params to trigger tags, add dm trigger type |
| `docs/CONTEXT.md` | Modify | Remove context_mode, adoption-list composition |
| `docs/GENESIS.md` | New | Genesis config documentation |
| `genesis.jsonc` | Modify | Add bootstrap_relays, flesh out default_skill and startup_events |
| `src/agent.c` | Major refactor | Adoption-list context assembly, DM trigger dispatch |
| `src/config.c` | Modify | Support genesis.jsonc format, nsec-only mode |
| `src/config.h` | Modify | New config structs for minimal runtime config |
| `src/main.c` | Modify | First-run detection, nsec CLI flag, genesis flow |
| `src/trigger_manager.c` | Modify | DM trigger type, execution params from tags |
| `src/trigger_manager.h` | Modify | New trigger type enum, execution param fields |
| `src/prompt_template.c` | Modify | {{variable}} resolution engine |
| `src/tools/tool_config.c` | New | config_store and config_recall tools |
| `src/tools/tools_dispatch.c` | Modify | Register new config tools |
| `src/tools/tools_schema.c` | Modify | Schema for new config tools |
| `README.md` | Modify | Updated startup docs |
| `context_template.md` | Deprecate | Replaced by didactyl_default skill |
| `config.jsonc.example` | Deprecate | Replaced by genesis.jsonc |
---
## Dependency Order
```mermaid
graph TD
P1[Phase 1: Spec Updates] --> P2[Phase 2: Genesis Startup]
P1 --> P3[Phase 3: Adoption-List Context]
P1 --> P4[Phase 4: Execution Params on Tags]
P1 --> P5[Phase 5: DM Trigger Type]
P2 --> P6[Phase 6: Encrypted Config Storage]
P3 --> P5
P4 --> P5
P5 --> P7[Phase 7: Documentation Cleanup]
P6 --> P7
```
Phases 2, 3, 4 can proceed in parallel after Phase 1. Phase 5 depends on 3 and 4. Phase 6 depends on 2. Phase 7 is last.

View File

@@ -1097,65 +1097,6 @@ void agent_append_context_log(const char* sender_pubkey_hex, const char* phase,
}
static int append_startup_events_context(cJSON* messages) {
if (!messages || !g_cfg || g_cfg->startup_event_count <= 0 || !g_cfg->startup_events) {
return 0;
}
cJSON* arr = cJSON_CreateArray();
if (!arr) {
return -1;
}
for (int i = 0; i < g_cfg->startup_event_count; i++) {
startup_event_t* se = &g_cfg->startup_events[i];
if (!se || se->kind == 31120) {
continue;
}
cJSON* item = cJSON_CreateObject();
if (!item) {
cJSON_Delete(arr);
return -1;
}
cJSON_AddNumberToObject(item, "kind", se->kind);
cJSON_AddStringToObject(item, "content", se->content ? se->content : "");
cJSON* tags = NULL;
if (se->tags_json) {
tags = cJSON_Parse(se->tags_json);
}
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(tags);
tags = cJSON_CreateArray();
}
cJSON_AddItemToObject(item, "tags", tags);
cJSON_AddItemToArray(arr, item);
}
char* events_json = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
if (!events_json) {
return -1;
}
const char* prefix = "Startup events memory (source: config.startup_events; kind 31120 excluded): ";
size_t out_len = strlen(prefix) + strlen(events_json) + 1U;
char* payload = (char*)malloc(out_len);
if (!payload) {
free(events_json);
return -1;
}
snprintf(payload, out_len, "%s%s", prefix, events_json);
free(events_json);
int rc = append_simple_message(messages, "system", payload);
free(payload);
return rc;
}
static char* build_sender_verification_text(didactyl_sender_tier_t sender_tier) {
if (sender_tier == DIDACTYL_SENDER_ADMIN) {
return strdup("This message has been cryptographically verified as coming from your administrator.");
@@ -1166,196 +1107,6 @@ static char* build_sender_verification_text(didactyl_sender_tier_t sender_tier)
return strdup("Sender tier is unknown.");
}
static char* build_admin_profile_plain_text(void) {
char* kind0 = nostr_handler_get_admin_kind0_context();
if (!kind0 || kind0[0] == '\0') {
free(kind0);
return strdup("Administrator profile: unavailable.");
}
cJSON* root = cJSON_Parse(kind0);
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
size_t n = strlen("Administrator profile raw JSON: ") + strlen(kind0) + 1U;
char* out = (char*)malloc(n);
if (!out) {
free(kind0);
return NULL;
}
snprintf(out, n, "Administrator profile raw JSON: %s", kind0);
free(kind0);
return out;
}
const char* name = "";
const char* display_name = "";
const char* about = "";
const char* website = "";
const char* picture = "";
cJSON* v = cJSON_GetObjectItemCaseSensitive(root, "name");
if (v && cJSON_IsString(v) && v->valuestring) name = v->valuestring;
v = cJSON_GetObjectItemCaseSensitive(root, "display_name");
if (v && cJSON_IsString(v) && v->valuestring) display_name = v->valuestring;
v = cJSON_GetObjectItemCaseSensitive(root, "about");
if (v && cJSON_IsString(v) && v->valuestring) about = v->valuestring;
v = cJSON_GetObjectItemCaseSensitive(root, "website");
if (v && cJSON_IsString(v) && v->valuestring) website = v->valuestring;
v = cJSON_GetObjectItemCaseSensitive(root, "picture");
if (v && cJSON_IsString(v) && v->valuestring) picture = v->valuestring;
const char* fmt = "Administrator profile: name=%s; display_name=%s; about=%s; website=%s; picture=%s";
int len = snprintf(NULL, 0, fmt, name, display_name, about, website, picture);
char* out = (char*)malloc((size_t)len + 1U);
if (out) {
snprintf(out, (size_t)len + 1U, fmt, name, display_name, about, website, picture);
}
cJSON_Delete(root);
free(kind0);
return out;
}
static char* build_admin_relay_list_plain_text(void) {
char* kind10002 = nostr_handler_get_admin_kind10002_context();
if (!kind10002 || kind10002[0] == '\0') {
free(kind10002);
return strdup("Administrator relay list: unavailable.");
}
cJSON* arr = cJSON_Parse(kind10002);
if (!arr || !cJSON_IsArray(arr)) {
cJSON_Delete(arr);
size_t n = strlen("Administrator relay list raw JSON: ") + strlen(kind10002) + 1U;
char* out = (char*)malloc(n);
if (!out) {
free(kind10002);
return NULL;
}
snprintf(out, n, "Administrator relay list raw JSON: %s", kind10002);
free(kind10002);
return out;
}
size_t cap = strlen("Administrator relay list: ") + strlen(kind10002) + 64U;
char* out = (char*)malloc(cap);
if (!out) {
cJSON_Delete(arr);
free(kind10002);
return NULL;
}
out[0] = '\0';
strcat(out, "Administrator relay list: ");
int n = cJSON_GetArraySize(arr);
int added = 0;
for (int i = 0; i < n; i++) {
cJSON* it = cJSON_GetArrayItem(arr, i);
if (!it || !cJSON_IsString(it) || !it->valuestring || it->valuestring[0] == '\0') continue;
if (added > 0) strcat(out, ", ");
strcat(out, it->valuestring);
added++;
}
if (added == 0) {
strcat(out, "none");
}
cJSON_Delete(arr);
free(kind10002);
return out;
}
static char* build_admin_recent_posts_text(void) {
char* notes = nostr_handler_get_admin_kind1_notes_context();
if (!notes || notes[0] == '\0') {
free(notes);
return strdup("Recent posts: unavailable.");
}
const char* prefix = "Administrator recent public notes:\n";
const char* body = notes;
if (strncmp(notes, prefix, strlen(prefix)) == 0) {
body = notes + strlen(prefix);
}
size_t n = strlen("Recent posts:\n") + strlen(body) + 1U;
char* out = (char*)malloc(n);
if (!out) {
free(notes);
return NULL;
}
snprintf(out, n, "Recent posts:\n%s", body);
free(notes);
return out;
}
static int append_admin_identity_context(cJSON* messages, didactyl_sender_tier_t sender_tier) {
if (!messages || !g_cfg) {
return -1;
}
char* sender_verification = build_sender_verification_text(sender_tier);
char* profile_plain = build_admin_profile_plain_text();
char* relay_plain = build_admin_relay_list_plain_text();
char* posts_plain = build_admin_recent_posts_text();
if (!sender_verification || !profile_plain || !relay_plain || !posts_plain) {
free(sender_verification);
free(profile_plain);
free(relay_plain);
free(posts_plain);
return -1;
}
const char* fmt =
"Sender verification: %s\n"
"Administrator pubkey (hex): %s\n"
"%s\n"
"%s\n"
"%s";
int len = snprintf(NULL,
0,
fmt,
sender_verification,
g_cfg->admin.pubkey[0] != '\0' ? g_cfg->admin.pubkey : "unknown",
profile_plain,
relay_plain,
posts_plain);
if (len < 0) {
free(sender_verification);
free(profile_plain);
free(relay_plain);
free(posts_plain);
return -1;
}
char* payload = (char*)malloc((size_t)len + 1U);
if (!payload) {
free(sender_verification);
free(profile_plain);
free(relay_plain);
free(posts_plain);
return -1;
}
snprintf(payload,
(size_t)len + 1U,
fmt,
sender_verification,
g_cfg->admin.pubkey[0] != '\0' ? g_cfg->admin.pubkey : "unknown",
profile_plain,
relay_plain,
posts_plain);
int rc = append_simple_message(messages, "system", payload);
free(payload);
free(sender_verification);
free(profile_plain);
free(relay_plain);
free(posts_plain);
return rc;
}
static int append_recent_admin_dm_history(cJSON* messages, const char* current_message) {
if (!messages || !g_cfg) {
@@ -1924,6 +1675,9 @@ void agent_on_trigger(const char* skill_d_tag,
return;
}
const char* prev_trigger_event_json = g_tools_ctx.template_trigger_event_json;
g_tools_ctx.template_trigger_event_json = event_json;
const char* relay = (relay_url && relay_url[0] != '\0') ? relay_url : "unknown";
const char* trigger_prefix =
@@ -1961,12 +1715,13 @@ void agent_on_trigger(const char* skill_d_tag,
}
snprintf(user_prompt, user_len, "Triggering event JSON:\n%s", event_json);
free(event_json);
char* tools_json = tools_build_openai_schema_json(&g_tools_ctx);
if (!tools_json) {
free(system_prompt);
free(user_prompt);
g_tools_ctx.template_trigger_event_json = prev_trigger_event_json;
free(event_json);
(void)nostr_handler_send_dm_auto(g_cfg->admin.pubkey, "Triggered skill execution failed: tools unavailable.");
return;
}
@@ -1979,6 +1734,8 @@ void agent_on_trigger(const char* skill_d_tag,
free(tools_json);
free(system_prompt);
free(user_prompt);
g_tools_ctx.template_trigger_event_json = prev_trigger_event_json;
free(event_json);
(void)nostr_handler_send_dm_auto(g_cfg->admin.pubkey, "Triggered skill execution failed: prompt initialization failed.");
return;
}
@@ -2006,6 +1763,8 @@ void agent_on_trigger(const char* skill_d_tag,
(void)nostr_handler_send_dm_auto(g_cfg->admin.pubkey, "Triggered skill execution failed: LLM request failed.");
cJSON_Delete(messages);
free(tools_json);
g_tools_ctx.template_trigger_event_json = prev_trigger_event_json;
free(event_json);
return;
}
@@ -2033,6 +1792,8 @@ void agent_on_trigger(const char* skill_d_tag,
llm_response_free(&resp);
cJSON_Delete(messages);
free(tools_json);
g_tools_ctx.template_trigger_event_json = prev_trigger_event_json;
free(event_json);
(void)nostr_handler_send_dm_auto(g_cfg->admin.pubkey, "Triggered skill execution failed: tool result append failed.");
return;
}
@@ -2052,6 +1813,8 @@ void agent_on_trigger(const char* skill_d_tag,
cJSON_Delete(messages);
free(tools_json);
g_tools_ctx.template_trigger_event_json = prev_trigger_event_json;
free(event_json);
}
int agent_build_admin_messages_json(const char* current_user_message,
@@ -2081,6 +1844,7 @@ int agent_build_admin_messages_json(const char* current_user_message,
g_tools_ctx.template_sender_tier = (int)sender_tier;
g_tools_ctx.template_current_user_message = current_user_message;
g_tools_ctx.template_trigger_event_json = NULL;
messages = prompt_template_build_messages(&g_prompt_template,
provider_name,
@@ -2093,6 +1857,7 @@ int agent_build_admin_messages_json(const char* current_user_message,
if (!messages) {
return -1;
}
} else {
messages = cJSON_CreateArray();
if (!messages) {
@@ -2110,8 +1875,6 @@ int agent_build_admin_messages_json(const char* current_user_message,
append_simple_message(messages, "system", g_system_context) != 0 ||
append_simple_message(messages, "system", agent_identity) != 0 ||
append_simple_message(messages, "system", sender_verification) != 0 ||
append_admin_identity_context(messages, sender_tier) != 0 ||
append_startup_events_context(messages) != 0 ||
append_adopted_skills_context(messages) != 0) {
free(sender_verification);
cJSON_Delete(messages);
@@ -2184,6 +1947,17 @@ 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;
}
}
if (!allow_tools) {
const char* tier_prefix = (tier == DIDACTYL_SENDER_WOT)
? "You are responding to a web-of-trust contact. Keep the response helpful and concise. Tool use is disabled for this tier."

View File

@@ -616,6 +616,91 @@ static int normalize_skill_d_tag(int event_kind, cJSON* item, cJSON* tags) {
return set_tag_value_string(tags, "d", d_tag);
}
static int parse_default_skill_config(cJSON* root, didactyl_config_t* config) {
if (!root || !config) {
return -1;
}
cJSON* def = cJSON_GetObjectItemCaseSensitive(root, "default_skill");
if (!def || !cJSON_IsObject(def)) {
return 0;
}
cJSON* kind = cJSON_GetObjectItemCaseSensitive(def, "kind");
cJSON* d_tag = cJSON_GetObjectItemCaseSensitive(def, "d_tag");
cJSON* content = cJSON_GetObjectItemCaseSensitive(def, "content");
cJSON* content_fields = cJSON_GetObjectItemCaseSensitive(def, "content_fields");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(def, "tags");
if (!kind || !cJSON_IsNumber(kind)) {
return -1;
}
int kind_i = (int)kind->valuedouble;
if (kind_i != 31123 && kind_i != 31124) {
return -1;
}
if (!d_tag || !cJSON_IsString(d_tag) || !d_tag->valuestring || d_tag->valuestring[0] == '\0') {
return -1;
}
if (strlen(d_tag->valuestring) >= sizeof(config->default_skill.d_tag)) {
return -1;
}
char* content_text = NULL;
if (content_fields) {
if (!cJSON_IsObject(content_fields)) {
return -1;
}
content_text = cJSON_PrintUnformatted(content_fields);
} else {
if (!content || !cJSON_IsString(content) || !content->valuestring) {
return -1;
}
content_text = strdup(content->valuestring);
}
if (!content_text) {
return -1;
}
cJSON* tags_work = NULL;
if (tags) {
if (!cJSON_IsArray(tags)) {
free(content_text);
return -1;
}
tags_work = cJSON_Duplicate(tags, 1);
} else {
tags_work = cJSON_CreateArray();
}
if (!tags_work || !cJSON_IsArray(tags_work)) {
cJSON_Delete(tags_work);
free(content_text);
return -1;
}
if (set_tag_value_string(tags_work, "d", d_tag->valuestring) != 0) {
cJSON_Delete(tags_work);
free(content_text);
return -1;
}
char* tags_json = cJSON_PrintUnformatted(tags_work);
cJSON_Delete(tags_work);
if (!tags_json) {
free(content_text);
return -1;
}
free(config->default_skill.content);
free(config->default_skill.tags_json);
config->default_skill.kind = kind_i;
snprintf(config->default_skill.d_tag, sizeof(config->default_skill.d_tag), "%s", d_tag->valuestring);
config->default_skill.content = content_text;
config->default_skill.tags_json = tags_json;
return 0;
}
static int parse_startup_events(cJSON* root, didactyl_config_t* config) {
cJSON* arr = cJSON_GetObjectItemCaseSensitive(root, "startup_events");
if (!arr || !cJSON_IsArray(arr)) {
@@ -753,9 +838,251 @@ static int parse_relays_from_startup_kind10002(didactyl_config_t* config) {
return -1;
}
static int set_default_bootstrap_relays(didactyl_config_t* config) {
if (!config) {
return -1;
}
static const char* defaults[] = {
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol"
};
const int relay_count = (int)(sizeof(defaults) / sizeof(defaults[0]));
char** relays = (char**)calloc((size_t)relay_count, sizeof(char*));
if (!relays) {
return -1;
}
for (int i = 0; i < relay_count; i++) {
relays[i] = strdup(defaults[i]);
if (!relays[i]) {
for (int j = 0; j < i; j++) {
free(relays[j]);
}
free(relays);
return -1;
}
}
if (config->relays) {
for (int i = 0; i < config->relay_count; i++) {
free(config->relays[i]);
}
free(config->relays);
}
config->relays = relays;
config->relay_count = relay_count;
return 0;
}
static int parse_relays(cJSON* root, didactyl_config_t* config) {
(void)root;
return parse_relays_from_startup_kind10002(config);
if (parse_relays_from_startup_kind10002(config) == 0) {
return 0;
}
return set_default_bootstrap_relays(config);
}
static int startup_event_has_d_tag(const startup_event_t* se, const char* d_tag) {
if (!se || !se->tags_json || !d_tag || d_tag[0] == '\0') {
return 0;
}
cJSON* tags = cJSON_Parse(se->tags_json);
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(tags);
return 0;
}
int found = 0;
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue;
cJSON* k = cJSON_GetArrayItem(tag, 0);
cJSON* v = cJSON_GetArrayItem(tag, 1);
if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v) || !k->valuestring || !v->valuestring) continue;
if (strcmp(k->valuestring, "d") == 0 && strcmp(v->valuestring, d_tag) == 0) {
found = 1;
break;
}
}
cJSON_Delete(tags);
return found;
}
static int adoption_tags_has_addr(cJSON* tags, const char* addr) {
if (!tags || !cJSON_IsArray(tags) || !addr || addr[0] == '\0') {
return 0;
}
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue;
cJSON* k = cJSON_GetArrayItem(tag, 0);
cJSON* v = cJSON_GetArrayItem(tag, 1);
if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v) || !k->valuestring || !v->valuestring) continue;
if (strcmp(k->valuestring, "a") == 0 && strcmp(v->valuestring, addr) == 0) {
return 1;
}
}
return 0;
}
static int append_adoption_addr_tag(cJSON* tags, const char* addr) {
if (!tags || !cJSON_IsArray(tags) || !addr || addr[0] == '\0') {
return -1;
}
cJSON* tag = cJSON_CreateArray();
if (!tag) return -1;
cJSON_AddItemToArray(tag, cJSON_CreateString("a"));
cJSON_AddItemToArray(tag, cJSON_CreateString(addr));
cJSON_AddItemToArray(tags, tag);
return 0;
}
static int append_startup_event(didactyl_config_t* config, int kind, const char* content, const char* tags_json) {
if (!config || !content) {
return -1;
}
int old_count = config->startup_event_count;
startup_event_t* grown = (startup_event_t*)realloc(config->startup_events, (size_t)(old_count + 1) * sizeof(startup_event_t));
if (!grown) {
return -1;
}
config->startup_events = grown;
memset(&config->startup_events[old_count], 0, sizeof(startup_event_t));
config->startup_events[old_count].kind = kind;
config->startup_events[old_count].content = strdup(content);
if (!config->startup_events[old_count].content) {
return -1;
}
if (tags_json && tags_json[0] != '\0') {
config->startup_events[old_count].tags_json = strdup(tags_json);
if (!config->startup_events[old_count].tags_json) {
free(config->startup_events[old_count].content);
config->startup_events[old_count].content = NULL;
return -1;
}
}
config->startup_event_count = old_count + 1;
return 0;
}
int config_ensure_default_skill_startup_events(didactyl_config_t* config) {
if (!config || !config->default_skill.content || config->default_skill.content[0] == '\0' ||
config->default_skill.d_tag[0] == '\0') {
return 0;
}
int has_default_skill_event = 0;
for (int i = 0; i < config->startup_event_count; i++) {
startup_event_t* se = &config->startup_events[i];
if (se->kind == config->default_skill.kind && startup_event_has_d_tag(se, config->default_skill.d_tag)) {
has_default_skill_event = 1;
break;
}
}
if (!has_default_skill_event) {
if (append_startup_event(config,
config->default_skill.kind,
config->default_skill.content,
config->default_skill.tags_json ? config->default_skill.tags_json : "[]") != 0) {
return -1;
}
}
if (config->keys.public_key_hex[0] == '\0') {
return 0;
}
char addr[256];
snprintf(addr,
sizeof(addr),
"%d:%s:%s",
config->default_skill.kind,
config->keys.public_key_hex,
config->default_skill.d_tag);
for (int i = 0; i < config->startup_event_count; i++) {
startup_event_t* se = &config->startup_events[i];
if (se->kind != 10123 || !se->tags_json) {
continue;
}
cJSON* tags = cJSON_Parse(se->tags_json);
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(tags);
continue;
}
if (adoption_tags_has_addr(tags, addr)) {
cJSON_Delete(tags);
return 0;
}
if (append_adoption_addr_tag(tags, addr) != 0) {
cJSON_Delete(tags);
return -1;
}
char* updated = cJSON_PrintUnformatted(tags);
cJSON_Delete(tags);
if (!updated) {
return -1;
}
free(se->tags_json);
se->tags_json = updated;
return 0;
}
cJSON* tags = cJSON_CreateArray();
if (!tags) {
return -1;
}
if (append_adoption_addr_tag(tags, addr) != 0) {
cJSON_Delete(tags);
return -1;
}
cJSON* app = cJSON_CreateArray();
cJSON* scope = cJSON_CreateArray();
if (!app || !scope) {
cJSON_Delete(app);
cJSON_Delete(scope);
cJSON_Delete(tags);
return -1;
}
cJSON_AddItemToArray(app, cJSON_CreateString("app"));
cJSON_AddItemToArray(app, cJSON_CreateString("didactyl"));
cJSON_AddItemToArray(scope, cJSON_CreateString("scope"));
cJSON_AddItemToArray(scope, cJSON_CreateString(config->default_skill.kind == 31124 ? "private" : "public"));
cJSON_AddItemToArray(tags, app);
cJSON_AddItemToArray(tags, scope);
char* tags_json = cJSON_PrintUnformatted(tags);
cJSON_Delete(tags);
if (!tags_json) {
return -1;
}
int rc = append_startup_event(config, 10123, "", tags_json);
free(tags_json);
return rc;
}
void config_free(didactyl_config_t* config) {
@@ -778,6 +1105,9 @@ void config_free(didactyl_config_t* config) {
free(config->startup_events);
}
free(config->default_skill.content);
free(config->default_skill.tags_json);
memset(config, 0, sizeof(*config));
}
@@ -831,9 +1161,21 @@ int config_load(const char* path, didactyl_config_t* config) {
config->api.port = 8484;
snprintf(config->api.bind_address, sizeof(config->api.bind_address), "%s", "127.0.0.1");
config->default_skill.kind = 31124;
config->default_skill.d_tag[0] = '\0';
config->default_skill.content = NULL;
config->default_skill.tags_json = NULL;
char* raw_buf = NULL;
size_t raw_len = 0;
if (read_file_to_buffer(path, &raw_buf, &raw_len) != 0) {
if (errno == ENOENT) {
if (set_default_bootstrap_relays(config) != 0) {
config_set_error("failed to initialize default bootstrap relays");
return -1;
}
return 0;
}
config_set_error("failed to read config file '%s': %s", path, strerror(errno));
return -1;
}
@@ -862,19 +1204,25 @@ int config_load(const char* path, didactyl_config_t* config) {
int rc = -1;
cJSON* keys = cJSON_GetObjectItemCaseSensitive(root, "keys");
if (!keys || !cJSON_IsObject(keys)) {
keys = cJSON_GetObjectItemCaseSensitive(root, "key");
}
cJSON* admin = cJSON_GetObjectItemCaseSensitive(root, "admin");
cJSON* llm = cJSON_GetObjectItemCaseSensitive(root, "llm");
if (!keys || !cJSON_IsObject(keys) ||
!admin || !cJSON_IsObject(admin) ||
if (!admin || !cJSON_IsObject(admin) ||
!llm || !cJSON_IsObject(llm)) {
config_set_error("config must include object sections: keys, admin, llm");
config_set_error("config must include object sections: admin, llm");
goto cleanup;
}
if (copy_json_string(keys, "nsec", config->keys.nsec, sizeof(config->keys.nsec), 1) != 0) {
config_set_error("keys.nsec is required and must be a non-empty string");
goto cleanup;
if (!keys || !cJSON_IsObject(keys)) {
config->keys.nsec[0] = '\0';
} else {
if (copy_json_string(keys, "nsec", config->keys.nsec, sizeof(config->keys.nsec), 0) != 0) {
config_set_error("keys.nsec/key.nsec must be a string when provided");
goto cleanup;
}
}
char admin_key_raw[OW_MAX_KEY_LEN] = {0};
@@ -892,8 +1240,13 @@ int config_load(const char* path, didactyl_config_t* config) {
goto cleanup;
}
if (parse_default_skill_config(root, config) != 0) {
config_set_error("invalid default_skill configuration");
goto cleanup;
}
if (parse_relays(root, config) != 0) {
config_set_error("relay configuration is invalid: startup_events must include kind 10002 with non-empty r tags");
config_set_error("relay configuration is invalid and bootstrap fallback failed");
goto cleanup;
}
@@ -954,17 +1307,19 @@ int config_load(const char* path, didactyl_config_t* config) {
goto cleanup;
}
if (decode_private_key(config->keys.nsec, config->keys.private_key) != 0) {
config_set_error("keys.nsec must be valid nsec1... or 64-char hex private key");
goto cleanup;
}
if (config->keys.nsec[0] != '\0') {
if (decode_private_key(config->keys.nsec, config->keys.private_key) != 0) {
config_set_error("keys.nsec/key.nsec must be valid nsec1... or 64-char hex private key");
goto cleanup;
}
if (nostr_ec_public_key_from_private_key(config->keys.private_key, config->keys.public_key) != 0) {
config_set_error("failed to derive public key from keys.nsec");
goto cleanup;
}
if (nostr_ec_public_key_from_private_key(config->keys.private_key, config->keys.public_key) != 0) {
config_set_error("failed to derive public key from keys.nsec/key.nsec");
goto cleanup;
}
nostr_bytes_to_hex(config->keys.public_key, 32, config->keys.public_key_hex);
nostr_bytes_to_hex(config->keys.public_key, 32, config->keys.public_key_hex);
}
rc = 0;

View File

@@ -61,6 +61,13 @@ typedef struct {
char* tags_json; // JSON array string for tags, optional
} startup_event_t;
typedef struct {
int kind;
char d_tag[65];
char* content;
char* tags_json;
} default_skill_config_t;
typedef struct {
int enabled;
int tools_enabled;
@@ -111,10 +118,12 @@ typedef struct {
api_config_t api;
startup_event_t* startup_events;
int startup_event_count;
default_skill_config_t default_skill;
char config_path[OW_MAX_URL_LEN];
} didactyl_config_t;
int config_load(const char* path, didactyl_config_t* config);
int config_ensure_default_skill_startup_events(didactyl_config_t* config);
const char* config_last_error(void);
void config_free(didactyl_config_t* config);

View File

@@ -484,8 +484,8 @@ int llm_set_config(const llm_config_t* config) {
return 0;
}
char* llm_list_models_json(const char* base_url_override) {
if (!g_initialized) {
char* llm_get_json_path(const char* base_url_override, const char* path) {
if (!g_initialized || !path || path[0] == '\0') {
return NULL;
}
@@ -493,11 +493,15 @@ char* llm_list_models_json(const char* base_url_override) {
? base_url_override
: g_cfg.base_url;
char url[OW_MAX_URL_LEN + 32];
snprintf(url, sizeof(url), "%s/models", base_url);
char url[OW_MAX_URL_LEN + 128];
snprintf(url, sizeof(url), "%s%s", base_url, path);
return perform_http_request(url, NULL, 0);
}
char* llm_list_models_json(const char* base_url_override) {
return llm_get_json_path(base_url_override, "/models");
}
void llm_cleanup(void) {
if (!g_initialized) {
return;

View File

@@ -29,6 +29,7 @@ void llm_response_free(llm_response_t* response);
int llm_get_config(llm_config_t* out_config);
int llm_set_config(const llm_config_t* config);
char* llm_list_models_json(const char* base_url_override);
char* llm_get_json_path(const char* base_url_override, const char* path);
void llm_cleanup(void);
#endif

1113
src/main.c

File diff suppressed because it is too large Load Diff

View File

@@ -12,8 +12,8 @@
// Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define DIDACTYL_VERSION_MAJOR 0
#define DIDACTYL_VERSION_MINOR 0
#define DIDACTYL_VERSION_PATCH 71
#define DIDACTYL_VERSION "v0.0.71"
#define DIDACTYL_VERSION_PATCH 72
#define DIDACTYL_VERSION "v0.0.72"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"

View File

@@ -122,6 +122,30 @@ static void nostr_core_log_bridge(int level, const char* component, const char*
}
}
#if defined(__GNUC__) || defined(__clang__)
extern void nostr_set_log_callback(nostr_log_callback_t callback, void* user_data) __attribute__((weak));
extern void nostr_set_log_level(nostr_log_level_t level) __attribute__((weak));
#endif
static void nostr_core_set_log_bridge_if_supported(void) {
#if defined(__GNUC__) || defined(__clang__)
if (nostr_set_log_callback) {
nostr_set_log_callback(nostr_core_log_bridge, NULL);
}
if (nostr_set_log_level) {
nostr_set_log_level((nostr_log_level_t)didactyl_debug_to_nostr_log_level());
}
#endif
}
static void nostr_core_clear_log_bridge_if_supported(void) {
#if defined(__GNUC__) || defined(__clang__)
if (nostr_set_log_callback) {
nostr_set_log_callback(NULL, NULL);
}
#endif
}
typedef struct {
char pubkey_hex[65];
dm_protocol_t protocol;
@@ -383,6 +407,35 @@ void nostr_handler_refresh_relay_statuses(void) {
}
}
int nostr_handler_sync_relays_from_config(void) {
if (!g_cfg || !g_pool || g_cfg->relay_count <= 0 || !g_cfg->relays) {
return -1;
}
for (int i = 0; i < g_cfg->relay_count; i++) {
const char* relay = g_cfg->relays[i];
if (!relay || relay[0] == '\0') {
continue;
}
if (nostr_relay_pool_add_relay(g_pool, relay) != NOSTR_SUCCESS) {
DEBUG_WARN("[didactyl] failed to add discovered relay to pool: %s", relay);
}
}
free(g_last_relay_statuses);
g_last_relay_statuses = (nostr_pool_relay_status_t*)calloc((size_t)g_cfg->relay_count,
sizeof(nostr_pool_relay_status_t));
if (!g_last_relay_statuses) {
return -1;
}
for (int i = 0; i < g_cfg->relay_count; i++) {
g_last_relay_statuses[i] = nostr_relay_pool_get_relay_status(g_pool, g_cfg->relays[i]);
}
return 0;
}
static void log_publish_targets(const char* action) {
if (!g_cfg) {
return;
@@ -520,6 +573,39 @@ static int parse_enabled_flag_local(const char* enabled_s) {
return (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1;
}
static int parse_skill_address_local(const char* addr, int* out_kind, char out_pubkey_hex[65], char out_d_tag[65]) {
if (!addr || !out_kind || !out_pubkey_hex || !out_d_tag) {
return -1;
}
const char* p1 = strchr(addr, ':');
if (!p1) return -1;
const char* p2 = strchr(p1 + 1, ':');
if (!p2) return -1;
int kind = atoi(addr);
if (kind != 31123 && kind != 31124) {
return -1;
}
size_t pub_len = (size_t)(p2 - (p1 + 1));
if (pub_len != 64U) {
return -1;
}
size_t d_len = strlen(p2 + 1);
if (d_len == 0U || d_len >= 65U) {
return -1;
}
memcpy(out_pubkey_hex, p1 + 1, pub_len);
out_pubkey_hex[pub_len] = '\0';
memcpy(out_d_tag, p2 + 1, d_len + 1U);
*out_kind = kind;
return 0;
}
static int self_skill_is_adopted_local(int kind, const char* pubkey_hex, const char* d_tag) {
if ((kind != 31123 && kind != 31124) || !pubkey_hex || !d_tag || d_tag[0] == '\0') {
return 0;
@@ -608,7 +694,8 @@ static void register_trigger_from_self_skill_event(cJSON* event) {
(strcmp(trigger, "nostr-subscription") == 0 ||
strcmp(trigger, "webhook") == 0 ||
strcmp(trigger, "cron") == 0 ||
strcmp(trigger, "chain") == 0);
strcmp(trigger, "chain") == 0 ||
strcmp(trigger, "dm") == 0);
if (!d_tag || d_tag[0] == '\0' ||
!trigger_supported ||
!filter || filter[0] == '\0') {
@@ -626,13 +713,34 @@ static void register_trigger_from_self_skill_event(cJSON* event) {
trigger_action_type_t action_type = TRIGGER_ACTION_LLM;
int is_enabled = parse_enabled_flag_local(enabled);
const char* llm = find_tag_value_local(tags, "llm");
const char* tools = find_tag_value_local(tags, "tools");
const char* max_tokens_s = find_tag_value_local(tags, "max_tokens");
const char* temperature_s = find_tag_value_local(tags, "temperature");
const char* seed_s = find_tag_value_local(tags, "seed");
int has_max_tokens = (max_tokens_s && max_tokens_s[0] != '\0') ? 1 : 0;
int max_tokens = has_max_tokens ? atoi(max_tokens_s) : 0;
int has_temperature = (temperature_s && temperature_s[0] != '\0') ? 1 : 0;
double temperature = has_temperature ? atof(temperature_s) : 0.0;
int has_seed = (seed_s && seed_s[0] != '\0') ? 1 : 0;
int seed = has_seed ? atoi(seed_s) : 0;
int rc = trigger_manager_add((trigger_manager_t*)g_trigger_manager,
d_tag,
content->valuestring,
filter,
action_type,
trigger,
is_enabled);
is_enabled,
llm,
tools,
has_max_tokens,
max_tokens,
has_temperature,
temperature,
has_seed,
seed);
if (rc == 0) {
DEBUG_INFO("[didactyl] live self-skill trigger registered d_tag=%s action=%s enabled=%d",
d_tag,
@@ -664,7 +772,17 @@ static void self_skill_cache_upsert_event_locked(cJSON* event) {
return;
}
if (!g_cfg || strcmp(pubkey->valuestring, g_cfg->keys.public_key_hex) != 0) {
if (!g_cfg) {
return;
}
int is_agent_author = (strcmp(pubkey->valuestring, g_cfg->keys.public_key_hex) == 0);
int is_admin_author = (g_cfg->admin.pubkey[0] != '\0' && strcmp(pubkey->valuestring, g_cfg->admin.pubkey) == 0);
if (!is_agent_author && !is_admin_author) {
return;
}
if (is_admin_author && kind_val != 31123) {
return;
}
@@ -1586,8 +1704,7 @@ int nostr_handler_init(didactyl_config_t* config) {
DEBUG_INFO("[didactyl] initializing relay pool with %d relays", g_cfg->relay_count);
nostr_set_log_callback(nostr_core_log_bridge, NULL);
nostr_set_log_level((nostr_log_level_t)didactyl_debug_to_nostr_log_level());
nostr_core_set_log_bridge_if_supported();
nostr_pool_reconnect_config_t reconnect = *nostr_pool_reconnect_config_default();
reconnect.enable_auto_reconnect = 1;
@@ -1833,6 +1950,8 @@ int nostr_handler_subscribe_self_skills(void) {
return -1;
}
int rc = 0;
cJSON* filter = cJSON_CreateObject();
cJSON* kinds = cJSON_CreateArray();
cJSON* authors = cJSON_CreateArray();
@@ -1867,11 +1986,52 @@ int nostr_handler_subscribe_self_skills(void) {
cJSON_Delete(filter);
if (!skill_sub) {
return -1;
rc = -1;
}
DEBUG_INFO("[didactyl] self-event subscriptions active for pubkey %.16s...", g_cfg->keys.public_key_hex);
return 0;
if (g_cfg->admin.pubkey[0] != '\0' && strcmp(g_cfg->admin.pubkey, g_cfg->keys.public_key_hex) != 0) {
cJSON* admin_filter = cJSON_CreateObject();
cJSON* admin_kinds = cJSON_CreateArray();
cJSON* admin_authors = cJSON_CreateArray();
if (!admin_filter || !admin_kinds || !admin_authors) {
cJSON_Delete(admin_filter);
cJSON_Delete(admin_kinds);
cJSON_Delete(admin_authors);
return -1;
}
cJSON_AddItemToArray(admin_kinds, cJSON_CreateNumber(31123));
cJSON_AddItemToObject(admin_filter, "kinds", admin_kinds);
cJSON_AddItemToArray(admin_authors, cJSON_CreateString(g_cfg->admin.pubkey));
cJSON_AddItemToObject(admin_filter, "authors", admin_authors);
cJSON_AddNumberToObject(admin_filter, "limit", 150);
nostr_pool_subscription_t* admin_skill_sub = nostr_relay_pool_subscribe(
g_pool,
(const char**)g_cfg->relays,
g_cfg->relay_count,
admin_filter,
on_self_skill_event,
on_eose,
NULL,
0,
0,
NOSTR_POOL_EOSE_FULL_SET,
30,
120);
cJSON_Delete(admin_filter);
if (!admin_skill_sub) {
rc = -1;
}
}
if (rc == 0) {
DEBUG_INFO("[didactyl] self/admin skill subscriptions active (self=%.16s..., admin=%.16s...)",
g_cfg->keys.public_key_hex,
g_cfg->admin.pubkey);
}
return rc;
}
int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) {
@@ -2357,10 +2517,6 @@ static void publish_pending_startup_events_for_relay_index(int relay_index, cons
DEBUG_WARN("[didactyl] startup event publish failed for kind=%d relay=%s", se->kind, relay_url);
}
if (se->kind == 31120 && !g_system_context) {
g_system_context = strdup(se->content);
}
cJSON_Delete(tags);
}
}
@@ -2386,12 +2542,8 @@ int nostr_handler_reconcile_startup_events(void) {
g_startup_publish_tracking_enabled = 1;
}
for (int i = 0; i < g_cfg->startup_event_count; i++) {
startup_event_t* se = &g_cfg->startup_events[i];
if (se->kind == 31120 && se->content && !g_system_context) {
g_system_context = strdup(se->content);
break;
}
if (g_cfg->default_skill.content && g_cfg->default_skill.content[0] != '\0') {
g_system_context = strdup(g_cfg->default_skill.content);
}
load_startup_display_name();
@@ -2411,6 +2563,127 @@ int nostr_handler_reconcile_startup_events(void) {
return 0;
}
int nostr_handler_load_system_context_from_adopted_skills(void) {
if (!g_cfg || !g_pool || g_cfg->keys.public_key_hex[0] == '\0') {
return -1;
}
cJSON* adoption_filter = cJSON_CreateObject();
cJSON* kinds = cJSON_CreateArray();
cJSON* authors = cJSON_CreateArray();
if (!adoption_filter || !kinds || !authors) {
cJSON_Delete(adoption_filter);
cJSON_Delete(kinds);
cJSON_Delete(authors);
return -1;
}
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10123));
cJSON_AddItemToObject(adoption_filter, "kinds", kinds);
cJSON_AddItemToArray(authors, cJSON_CreateString(g_cfg->keys.public_key_hex));
cJSON_AddItemToObject(adoption_filter, "authors", authors);
cJSON_AddNumberToObject(adoption_filter, "limit", 1);
char* adoption_events_json = nostr_handler_query_json(adoption_filter, 3000);
cJSON_Delete(adoption_filter);
if (!adoption_events_json) {
return -1;
}
cJSON* adoption_events = cJSON_Parse(adoption_events_json);
free(adoption_events_json);
if (!adoption_events || !cJSON_IsArray(adoption_events) || cJSON_GetArraySize(adoption_events) <= 0) {
cJSON_Delete(adoption_events);
return -1;
}
cJSON* adoption = cJSON_GetArrayItem(adoption_events, 0);
cJSON* tags = adoption ? cJSON_GetObjectItemCaseSensitive(adoption, "tags") : NULL;
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(adoption_events);
return -1;
}
const char* first_addr = NULL;
int tag_n = cJSON_GetArraySize(tags);
for (int i = 0; i < tag_n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* k = cJSON_GetArrayItem(tag, 0);
cJSON* v = cJSON_GetArrayItem(tag, 1);
if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v) || !k->valuestring || !v->valuestring) {
continue;
}
if (strcmp(k->valuestring, "a") == 0 && v->valuestring[0] != '\0') {
first_addr = v->valuestring;
break;
}
}
if (!first_addr) {
cJSON_Delete(adoption_events);
return -1;
}
int skill_kind = 0;
char skill_pubkey_hex[65] = {0};
char skill_d_tag[65] = {0};
if (parse_skill_address_local(first_addr, &skill_kind, skill_pubkey_hex, skill_d_tag) != 0) {
cJSON_Delete(adoption_events);
return -1;
}
cJSON* skill_filter = cJSON_CreateObject();
cJSON* skill_kinds = cJSON_CreateArray();
cJSON* skill_authors = cJSON_CreateArray();
cJSON* d_tags = cJSON_CreateArray();
if (!skill_filter || !skill_kinds || !skill_authors || !d_tags) {
cJSON_Delete(adoption_events);
cJSON_Delete(skill_filter);
cJSON_Delete(skill_kinds);
cJSON_Delete(skill_authors);
cJSON_Delete(d_tags);
return -1;
}
cJSON_AddItemToArray(skill_kinds, cJSON_CreateNumber(skill_kind));
cJSON_AddItemToObject(skill_filter, "kinds", skill_kinds);
cJSON_AddItemToArray(skill_authors, cJSON_CreateString(skill_pubkey_hex));
cJSON_AddItemToObject(skill_filter, "authors", skill_authors);
cJSON_AddItemToArray(d_tags, cJSON_CreateString(skill_d_tag));
cJSON_AddItemToObject(skill_filter, "#d", d_tags);
cJSON_AddNumberToObject(skill_filter, "limit", 1);
char* skill_events_json = nostr_handler_query_json(skill_filter, 3000);
cJSON_Delete(skill_filter);
cJSON_Delete(adoption_events);
if (!skill_events_json) {
return -1;
}
cJSON* skill_events = cJSON_Parse(skill_events_json);
free(skill_events_json);
if (!skill_events || !cJSON_IsArray(skill_events) || cJSON_GetArraySize(skill_events) <= 0) {
cJSON_Delete(skill_events);
return -1;
}
cJSON* skill_event = cJSON_GetArrayItem(skill_events, 0);
cJSON* content = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "content") : NULL;
if (!content || !cJSON_IsString(content) || !content->valuestring) {
cJSON_Delete(skill_events);
return -1;
}
free(g_system_context);
g_system_context = strdup(content->valuestring);
cJSON_Delete(skill_events);
return g_system_context ? 0 : -1;
}
const char* nostr_handler_get_system_context(void) {
return g_system_context;
}
@@ -3085,12 +3358,6 @@ int nostr_handler_poll(int timeout_ms) {
elapsed_ms,
rc,
(unsigned long long)g_poll_counter);
} else if ((g_poll_counter % 200ULL) == 0ULL) {
DEBUG_TRACE("[didactyl] poll heartbeat: timeout=%d elapsed=%.1fms rc=%d count=%llu",
timeout_ms,
elapsed_ms,
rc,
(unsigned long long)g_poll_counter);
}
}
}
@@ -3105,7 +3372,7 @@ void nostr_handler_cleanup(void) {
nostr_relay_pool_destroy(g_pool);
}
nostr_set_log_callback(NULL, NULL);
nostr_core_clear_log_bridge_if_supported();
free(g_last_relay_statuses);
g_last_relay_statuses = NULL;

View File

@@ -58,7 +58,9 @@ nostr_pool_subscription_t* nostr_handler_subscribe_with_filter(
int nostr_handler_close_subscription(nostr_pool_subscription_t* subscription);
int nostr_handler_poll(int timeout_ms);
int nostr_handler_reconcile_startup_events(void);
int nostr_handler_load_system_context_from_adopted_skills(void);
void nostr_handler_refresh_relay_statuses(void);
int nostr_handler_sync_relays_from_config(void);
const char* nostr_handler_get_system_context(void);
char* nostr_handler_get_self_skill_events_json(void);
const char* nostr_handler_get_startup_display_name(void);

View File

@@ -97,6 +97,116 @@ static int append_text(char** buf, size_t* cap, size_t* used, const char* s) {
return 0;
}
static const char* map_variable_tool_name(const char* var_name) {
if (!var_name || var_name[0] == '\0') return NULL;
if (strcmp(var_name, "admin_profile") == 0) return "nostr_admin_profile";
if (strcmp(var_name, "admin_notes") == 0) return "nostr_admin_notes";
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;
}
static char* resolve_inline_variables_local(const char* tpl, tools_context_t* tools_ctx) {
if (!tpl) return strdup("");
size_t cap = strlen(tpl) + 64U;
size_t used = 0U;
char* out = (char*)malloc(cap);
if (!out) return NULL;
out[0] = '\0';
const char* p = tpl;
while (*p) {
const char* open = strstr(p, "{{");
if (!open) {
if (append_text(&out, &cap, &used, p) != 0) {
free(out);
return NULL;
}
break;
}
if (open > p) {
char* prefix = dup_range(p, (size_t)(open - p));
if (!prefix) {
free(out);
return NULL;
}
int rc = append_text(&out, &cap, &used, prefix);
free(prefix);
if (rc != 0) {
free(out);
return NULL;
}
}
const char* close = strstr(open + 2, "}}");
if (!close) {
if (append_text(&out, &cap, &used, open) != 0) {
free(out);
return NULL;
}
break;
}
char* var = dup_range(open + 2, (size_t)(close - (open + 2)));
if (!var) {
free(out);
return NULL;
}
char* var_trim = ltrim_inplace(var);
rtrim_inplace(var_trim);
const char* replacement = "";
char* replacement_owned = NULL;
if (strcmp(var_trim, "message") == 0) {
replacement = (tools_ctx && tools_ctx->template_current_user_message)
? tools_ctx->template_current_user_message
: "";
} else if (strcmp(var_trim, "dm_history") == 0) {
replacement = "";
} else if (tools_ctx) {
const char* tool_name = map_variable_tool_name(var_trim);
if (tool_name && tool_name[0] != '\0') {
char* tool_result = tools_execute(tools_ctx, tool_name, "{}");
if (tool_result) {
cJSON* root = cJSON_Parse(tool_result);
if (root && cJSON_IsObject(root)) {
cJSON* success = cJSON_GetObjectItemCaseSensitive(root, "success");
if (!success || !cJSON_IsBool(success) || cJSON_IsTrue(success)) {
cJSON* content = cJSON_GetObjectItemCaseSensitive(root, "content");
if (content && cJSON_IsString(content) && content->valuestring) {
replacement_owned = strdup(content->valuestring);
} else if (content) {
replacement_owned = cJSON_PrintUnformatted(content);
}
}
}
cJSON_Delete(root);
free(tool_result);
}
}
replacement = replacement_owned ? replacement_owned : "";
}
if (append_text(&out, &cap, &used, replacement) != 0) {
free(replacement_owned);
free(var);
free(out);
return NULL;
}
free(replacement_owned);
free(var);
p = close + 2;
}
return out;
}
static void init_section_defaults(prompt_template_section_t* sec) {
if (!sec) return;
memset(sec->name, 0, sizeof(sec->name));
@@ -118,20 +228,20 @@ static int parse_int_or_zero(const char* s) {
return atoi(s);
}
int prompt_template_parse(const char* soul_content, prompt_template_t* out_template) {
if (!soul_content || !out_template) {
int prompt_template_parse(const char* skill_content, prompt_template_t* out_template) {
if (!skill_content || !out_template) {
return -1;
}
memset(out_template, 0, sizeof(*out_template));
const char* marker = strstr(soul_content, PROMPT_TEMPLATE_MARKER);
const char* marker = strstr(skill_content, PROMPT_TEMPLATE_MARKER);
if (!marker) {
return -1;
}
size_t personality_len = (size_t)(marker - soul_content);
out_template->personality = dup_range(soul_content, personality_len);
size_t personality_len = (size_t)(marker - skill_content);
out_template->personality = dup_range(skill_content, personality_len);
if (!out_template->personality) {
return -1;
}
@@ -500,7 +610,7 @@ cJSON* prompt_template_build_messages(const prompt_template_t* tmpl,
strcmp(provider_name, sec->provider_name) == 0) {
tpl = sec->provider_content_template;
}
resolved = strdup(tpl ? tpl : "");
resolved = resolve_inline_variables_local(tpl ? tpl : "", tools_ctx);
}
if (!resolved) {
cJSON_Delete(out);

View File

@@ -32,7 +32,7 @@ typedef void (*prompt_template_emit_hook_fn)(const char* section_name,
int message_index,
void* user_data);
int prompt_template_parse(const char* soul_content, prompt_template_t* out_template);
int prompt_template_parse(const char* skill_content, prompt_template_t* out_template);
cJSON* prompt_template_build_messages(const prompt_template_t* tmpl,
const char* provider_name,

1586
src/setup_wizard.c Normal file

File diff suppressed because it is too large Load Diff

24
src/setup_wizard.h Normal file
View File

@@ -0,0 +1,24 @@
#ifndef DIDACTYL_SETUP_WIZARD_H
#define DIDACTYL_SETUP_WIZARD_H
#include <stddef.h>
#include "config.h"
/*
* setup_wizard_run
*
* Return values:
* 0 -> bootstrap/overwrite mode selected (new agent or load genesis), continue boot
* 1 -> wrote config/genesis and requested exit (no boot)
* 2 -> existing-agent recovery mode selected, continue boot
* -1 -> abort/error
*/
#define SETUP_WIZARD_RC_BOOTSTRAP 0
#define SETUP_WIZARD_RC_EXIT 1
#define SETUP_WIZARD_RC_EXISTING 2
#define SETUP_WIZARD_RC_ABORT -1
int setup_wizard_run(didactyl_config_t* config, char* genesis_path_out, size_t genesis_path_out_size);
#endif

View File

@@ -234,6 +234,80 @@ char* execute_nostr_agent_notes(tools_context_t* ctx, const char* args_json) {
return json;
}
char* execute_adopted_skills(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
cJSON* filter = cJSON_CreateObject();
cJSON* kinds = cJSON_CreateArray();
cJSON* authors = cJSON_CreateArray();
if (!filter || !kinds || !authors) {
cJSON_Delete(filter);
cJSON_Delete(kinds);
cJSON_Delete(authors);
return NULL;
}
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10123));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToArray(authors, cJSON_CreateString(ctx->cfg->keys.public_key_hex));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON_AddNumberToObject(filter, "limit", 1);
char* events_json = nostr_handler_query_json(filter, 3000);
cJSON_Delete(filter);
cJSON* out = cJSON_CreateObject();
if (!out) {
free(events_json);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "content", (events_json && events_json[0]) ? events_json : "[]");
cJSON_AddStringToObject(out, "adoption_events_json", (events_json && events_json[0]) ? events_json : "[]");
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(events_json);
return json;
}
char* execute_trigger_event(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
const char* trigger_json = (ctx->template_trigger_event_json && ctx->template_trigger_event_json[0] != '\0')
? ctx->template_trigger_event_json
: NULL;
if (!trigger_json) {
cJSON* arg_json = cJSON_GetObjectItemCaseSensitive(args, "trigger_event_json");
if (arg_json && cJSON_IsString(arg_json) && arg_json->valuestring && arg_json->valuestring[0] != '\0') {
trigger_json = arg_json->valuestring;
}
}
cJSON_Delete(args);
if (!trigger_json) {
trigger_json = "{}";
}
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "content", trigger_json);
cJSON_AddStringToObject(out, "trigger_event_json", trigger_json);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_agent_version(const char* args_json) {
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");

285
src/tools/tool_config.c Normal file
View File

@@ -0,0 +1,285 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cjson/cJSON.h"
#include "../nostr_handler.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
#define CONFIG_KIND 30078
#define CONFIG_APP_TAG "didactyl"
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
static cJSON* parse_args_local(const char* args_json) {
const char* raw = args_json ? args_json : "{}";
cJSON* args = cJSON_Parse(raw);
if (!args || !cJSON_IsObject(args)) {
cJSON_Delete(args);
return NULL;
}
return args;
}
static int nip44_encrypt_self_local(tools_context_t* ctx, const char* plaintext, char** out_ciphertext) {
if (!ctx || !ctx->cfg || !out_ciphertext) return -1;
const char* plain = plaintext ? plaintext : "";
size_t out_cap = (strlen(plain) * 4U) + 1024U;
char* ciphertext = (char*)malloc(out_cap);
if (!ciphertext) return -1;
int rc = nostr_nip44_encrypt(ctx->cfg->keys.private_key,
ctx->cfg->keys.public_key,
plain,
ciphertext,
out_cap);
if (rc != NOSTR_SUCCESS) {
free(ciphertext);
return -1;
}
*out_ciphertext = ciphertext;
return 0;
}
static int nip44_decrypt_self_local(tools_context_t* ctx, const char* ciphertext, char** out_plaintext) {
if (!ctx || !ctx->cfg || !ciphertext || !out_plaintext) return -1;
size_t out_cap = strlen(ciphertext) + 1024U;
char* plaintext = (char*)malloc(out_cap);
if (!plaintext) return -1;
int rc = nostr_nip44_decrypt(ctx->cfg->keys.private_key,
ctx->cfg->keys.public_key,
ciphertext,
plaintext,
out_cap);
if (rc != NOSTR_SUCCESS) {
free(plaintext);
return -1;
}
*out_plaintext = plaintext;
return 0;
}
static int query_config_ciphertext_local(tools_context_t* ctx, const char* d_tag, char** out_ciphertext) {
if (!ctx || !ctx->cfg || !d_tag || !d_tag[0] || !out_ciphertext) return -1;
cJSON* filter = cJSON_CreateObject();
cJSON* kinds = cJSON_CreateArray();
cJSON* authors = cJSON_CreateArray();
cJSON* d_vals = cJSON_CreateArray();
if (!filter || !kinds || !authors || !d_vals) {
cJSON_Delete(filter);
cJSON_Delete(kinds);
cJSON_Delete(authors);
cJSON_Delete(d_vals);
return -1;
}
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(CONFIG_KIND));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToArray(authors, cJSON_CreateString(ctx->cfg->keys.public_key_hex));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON_AddItemToArray(d_vals, cJSON_CreateString(d_tag));
cJSON_AddItemToObject(filter, "#d", d_vals);
cJSON_AddNumberToObject(filter, "limit", 1);
char* events_json = nostr_handler_query_json(filter, 4000);
cJSON_Delete(filter);
if (!events_json) {
return -1;
}
cJSON* arr = cJSON_Parse(events_json);
free(events_json);
if (!arr || !cJSON_IsArray(arr) || cJSON_GetArraySize(arr) <= 0) {
cJSON_Delete(arr);
*out_ciphertext = strdup("");
return (*out_ciphertext != NULL) ? 0 : -1;
}
cJSON* ev = cJSON_GetArrayItem(arr, 0);
cJSON* content = ev ? cJSON_GetObjectItemCaseSensitive(ev, "content") : NULL;
const char* cipher = (content && cJSON_IsString(content) && content->valuestring) ? content->valuestring : "";
*out_ciphertext = strdup(cipher);
cJSON_Delete(arr);
return (*out_ciphertext != NULL) ? 0 : -1;
}
char* execute_config_store(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* d_tag_j = cJSON_GetObjectItemCaseSensitive(args, "d_tag");
cJSON* content_j = cJSON_GetObjectItemCaseSensitive(args, "content");
if (!d_tag_j || !cJSON_IsString(d_tag_j) || !d_tag_j->valuestring || d_tag_j->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error_local("config_store requires non-empty string d_tag");
}
if (!content_j) {
cJSON_Delete(args);
return json_error_local("config_store requires content");
}
char* content_text = NULL;
if (cJSON_IsString(content_j) && content_j->valuestring) {
content_text = strdup(content_j->valuestring);
} else {
content_text = cJSON_PrintUnformatted(content_j);
}
cJSON_Delete(args);
if (!content_text) {
return json_error_local("config_store failed to serialize content");
}
char* ciphertext = NULL;
if (nip44_encrypt_self_local(ctx, content_text, &ciphertext) != 0) {
free(content_text);
return json_error_local("config_store NIP-44 encryption failed");
}
cJSON* tags = cJSON_CreateArray();
if (!tags) {
free(content_text);
free(ciphertext);
return NULL;
}
cJSON* d_tag = cJSON_CreateArray();
cJSON* app_tag = cJSON_CreateArray();
if (!d_tag || !app_tag) {
cJSON_Delete(d_tag);
cJSON_Delete(app_tag);
cJSON_Delete(tags);
free(content_text);
free(ciphertext);
return NULL;
}
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
cJSON_AddItemToArray(d_tag, cJSON_CreateString(d_tag_j->valuestring));
cJSON_AddItemToArray(tags, d_tag);
cJSON_AddItemToArray(app_tag, cJSON_CreateString("app"));
cJSON_AddItemToArray(app_tag, cJSON_CreateString(CONFIG_APP_TAG));
cJSON_AddItemToArray(tags, app_tag);
nostr_publish_result_t publish_result;
memset(&publish_result, 0, sizeof(publish_result));
int rc = nostr_handler_publish_kind_event(CONFIG_KIND, ciphertext, tags, &publish_result);
cJSON_Delete(tags);
free(ciphertext);
if (rc != 0) {
free(content_text);
nostr_handler_publish_result_free(&publish_result);
return json_error_local("config_store publish failed");
}
cJSON* out = cJSON_CreateObject();
if (!out) {
free(content_text);
nostr_handler_publish_result_free(&publish_result);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "d_tag", d_tag_j->valuestring);
cJSON_AddNumberToObject(out, "kind", CONFIG_KIND);
cJSON_AddNumberToObject(out, "content_length", (double)strlen(content_text));
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(content_text);
nostr_handler_publish_result_free(&publish_result);
return json;
}
char* execute_config_recall(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* d_tag_j = cJSON_GetObjectItemCaseSensitive(args, "d_tag");
if (!d_tag_j || !cJSON_IsString(d_tag_j) || !d_tag_j->valuestring || d_tag_j->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error_local("config_recall requires non-empty string d_tag");
}
char d_tag_buf[256];
snprintf(d_tag_buf, sizeof(d_tag_buf), "%s", d_tag_j->valuestring);
cJSON_Delete(args);
char* ciphertext = NULL;
if (query_config_ciphertext_local(ctx, d_tag_buf, &ciphertext) != 0) {
return json_error_local("config_recall query failed");
}
if (!ciphertext || ciphertext[0] == '\0') {
free(ciphertext);
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddBoolToObject(out, "found", 0);
cJSON_AddStringToObject(out, "d_tag", d_tag_buf);
cJSON_AddStringToObject(out, "content", "");
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* plaintext = NULL;
if (nip44_decrypt_self_local(ctx, ciphertext, &plaintext) != 0) {
free(ciphertext);
return json_error_local("config_recall NIP-44 decrypt failed");
}
free(ciphertext);
cJSON* out = cJSON_CreateObject();
if (!out) {
free(plaintext);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddBoolToObject(out, "found", 1);
cJSON_AddStringToObject(out, "d_tag", d_tag_buf);
cJSON_AddStringToObject(out, "content", plaintext);
cJSON_AddNumberToObject(out, "content_length", (double)strlen(plaintext));
cJSON* parsed = cJSON_Parse(plaintext);
if (parsed) {
cJSON_AddItemToObject(out, "content_json", parsed);
}
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(plaintext);
return json;
}

412
src/tools/tool_memory.c Normal file
View File

@@ -0,0 +1,412 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cjson/cJSON.h"
#include "../nostr_handler.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
#define MEMORY_KIND 30078
#define MEMORY_D_TAG "memory"
#define MEMORY_APP_TAG "didactyl"
#define MEMORY_MAX_CHARS 32000
static pthread_mutex_t g_memory_mutex = PTHREAD_MUTEX_INITIALIZER;
static char* g_memory_plain = NULL;
static int g_memory_loaded = 0;
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
static cJSON* parse_args_local(const char* args_json) {
const char* raw = args_json ? args_json : "{}";
cJSON* args = cJSON_Parse(raw);
if (!args || !cJSON_IsObject(args)) {
cJSON_Delete(args);
return NULL;
}
return args;
}
static char* strdup_nonnull_local(const char* s) {
return strdup(s ? s : "");
}
static void memory_cache_set_locked_local(const char* plaintext) {
free(g_memory_plain);
g_memory_plain = strdup_nonnull_local(plaintext);
g_memory_loaded = 1;
}
static char* memory_cache_get_copy_locked_local(void) {
return strdup(g_memory_plain ? g_memory_plain : "");
}
static int nip44_encrypt_self_local(tools_context_t* ctx, const char* plaintext, char** out_ciphertext) {
if (!ctx || !ctx->cfg || !out_ciphertext) return -1;
const char* plain = plaintext ? plaintext : "";
size_t out_cap = (strlen(plain) * 4U) + 1024U;
char* ciphertext = (char*)malloc(out_cap);
if (!ciphertext) return -1;
int rc = nostr_nip44_encrypt(ctx->cfg->keys.private_key,
ctx->cfg->keys.public_key,
plain,
ciphertext,
out_cap);
if (rc != NOSTR_SUCCESS) {
free(ciphertext);
return -1;
}
*out_ciphertext = ciphertext;
return 0;
}
static int nip44_decrypt_self_local(tools_context_t* ctx, const char* ciphertext, char** out_plaintext) {
if (!ctx || !ctx->cfg || !ciphertext || !out_plaintext) return -1;
size_t out_cap = strlen(ciphertext) + 1024U;
char* plaintext = (char*)malloc(out_cap);
if (!plaintext) return -1;
int rc = nostr_nip44_decrypt(ctx->cfg->keys.private_key,
ctx->cfg->keys.public_key,
ciphertext,
plaintext,
out_cap);
if (rc != NOSTR_SUCCESS) {
free(plaintext);
return -1;
}
*out_plaintext = plaintext;
return 0;
}
static int query_memory_ciphertext_local(tools_context_t* ctx, char** out_ciphertext) {
if (!ctx || !ctx->cfg || !out_ciphertext) return -1;
cJSON* filter = cJSON_CreateObject();
cJSON* kinds = cJSON_CreateArray();
cJSON* authors = cJSON_CreateArray();
cJSON* d_vals = cJSON_CreateArray();
if (!filter || !kinds || !authors || !d_vals) {
cJSON_Delete(filter);
cJSON_Delete(kinds);
cJSON_Delete(authors);
cJSON_Delete(d_vals);
return -1;
}
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(MEMORY_KIND));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToArray(authors, cJSON_CreateString(ctx->cfg->keys.public_key_hex));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON_AddItemToArray(d_vals, cJSON_CreateString(MEMORY_D_TAG));
cJSON_AddItemToObject(filter, "#d", d_vals);
cJSON_AddNumberToObject(filter, "limit", 1);
char* events_json = nostr_handler_query_json(filter, 4000);
cJSON_Delete(filter);
if (!events_json) {
return -1;
}
cJSON* arr = cJSON_Parse(events_json);
free(events_json);
if (!arr || !cJSON_IsArray(arr) || cJSON_GetArraySize(arr) <= 0) {
cJSON_Delete(arr);
*out_ciphertext = strdup("");
return (*out_ciphertext != NULL) ? 0 : -1;
}
cJSON* ev = cJSON_GetArrayItem(arr, 0);
cJSON* content = ev ? cJSON_GetObjectItemCaseSensitive(ev, "content") : NULL;
const char* cipher = (content && cJSON_IsString(content) && content->valuestring) ? content->valuestring : "";
*out_ciphertext = strdup(cipher);
cJSON_Delete(arr);
return (*out_ciphertext != NULL) ? 0 : -1;
}
static int memory_load_local(tools_context_t* ctx) {
char* ciphertext = NULL;
if (query_memory_ciphertext_local(ctx, &ciphertext) != 0) {
return -1;
}
char* plaintext = NULL;
if (!ciphertext || ciphertext[0] == '\0') {
plaintext = strdup("");
} else if (nip44_decrypt_self_local(ctx, ciphertext, &plaintext) != 0) {
fprintf(stdout,
"[didactyl] warning: failed to NIP-44 decrypt memory; resetting to empty\n");
plaintext = strdup("");
}
free(ciphertext);
if (!plaintext) return -1;
pthread_mutex_lock(&g_memory_mutex);
memory_cache_set_locked_local(plaintext);
pthread_mutex_unlock(&g_memory_mutex);
free(plaintext);
return 0;
}
static char* trim_and_limit_entry_local(const char* in, int* out_truncated) {
if (out_truncated) *out_truncated = 0;
if (!in) return strdup("");
while (*in == ' ' || *in == '\t' || *in == '\r' || *in == '\n') in++;
size_t n = strlen(in);
while (n > 0 && (in[n - 1] == ' ' || in[n - 1] == '\t' || in[n - 1] == '\r' || in[n - 1] == '\n')) {
n--;
}
if (n > MEMORY_MAX_CHARS) {
n = MEMORY_MAX_CHARS;
if (out_truncated) *out_truncated = 1;
}
char* out = (char*)malloc(n + 1U);
if (!out) return NULL;
if (n > 0) memcpy(out, in, n);
out[n] = '\0';
return out;
}
static char* prepend_and_truncate_local(const char* existing, const char* incoming, int* out_truncated) {
if (out_truncated) *out_truncated = 0;
const char* old = existing ? existing : "";
const char* add = incoming ? incoming : "";
const char* sep = "\n\n---\n\n";
size_t add_len = strlen(add);
size_t old_len = strlen(old);
size_t sep_len = (add_len > 0 && old_len > 0) ? strlen(sep) : 0U;
size_t total_len = add_len + sep_len + old_len;
char* joined = (char*)malloc(total_len + 1U);
if (!joined) return NULL;
size_t used = 0U;
if (add_len > 0) {
memcpy(joined + used, add, add_len);
used += add_len;
}
if (sep_len > 0) {
memcpy(joined + used, sep, sep_len);
used += sep_len;
}
if (old_len > 0) {
memcpy(joined + used, old, old_len);
used += old_len;
}
joined[used] = '\0';
if (used <= MEMORY_MAX_CHARS) {
return joined;
}
if (out_truncated) *out_truncated = 1;
char* clipped = (char*)malloc(MEMORY_MAX_CHARS + 1U);
if (!clipped) {
free(joined);
return NULL;
}
memcpy(clipped, joined, MEMORY_MAX_CHARS);
clipped[MEMORY_MAX_CHARS] = '\0';
free(joined);
return clipped;
}
int memory_init(tools_context_t* ctx) {
if (!ctx || !ctx->cfg) return -1;
return memory_load_local(ctx);
}
void memory_cleanup(void) {
pthread_mutex_lock(&g_memory_mutex);
free(g_memory_plain);
g_memory_plain = NULL;
g_memory_loaded = 0;
pthread_mutex_unlock(&g_memory_mutex);
}
char* execute_memory_save(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* content_j = cJSON_GetObjectItemCaseSensitive(args, "content");
if (!content_j || !cJSON_IsString(content_j) || !content_j->valuestring) {
cJSON_Delete(args);
return json_error_local("memory_save requires string content");
}
int entry_truncated = 0;
char* incoming = trim_and_limit_entry_local(content_j->valuestring, &entry_truncated);
cJSON_Delete(args);
if (!incoming) return NULL;
if (incoming[0] == '\0') {
free(incoming);
return json_error_local("memory_save content cannot be empty");
}
pthread_mutex_lock(&g_memory_mutex);
char* old_copy = memory_cache_get_copy_locked_local();
pthread_mutex_unlock(&g_memory_mutex);
if (!old_copy) {
free(incoming);
return NULL;
}
int body_truncated = 0;
char* merged = prepend_and_truncate_local(old_copy, incoming, &body_truncated);
free(old_copy);
free(incoming);
if (!merged) return NULL;
char* ciphertext = NULL;
if (nip44_encrypt_self_local(ctx, merged, &ciphertext) != 0) {
free(merged);
return json_error_local("memory_save NIP-44 encryption failed");
}
cJSON* tags = cJSON_CreateArray();
if (!tags) {
free(merged);
free(ciphertext);
return NULL;
}
cJSON* d_tag = cJSON_CreateArray();
cJSON* app_tag = cJSON_CreateArray();
if (!d_tag || !app_tag) {
cJSON_Delete(d_tag);
cJSON_Delete(app_tag);
cJSON_Delete(tags);
free(merged);
free(ciphertext);
return NULL;
}
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
cJSON_AddItemToArray(d_tag, cJSON_CreateString(MEMORY_D_TAG));
cJSON_AddItemToArray(tags, d_tag);
cJSON_AddItemToArray(app_tag, cJSON_CreateString("app"));
cJSON_AddItemToArray(app_tag, cJSON_CreateString(MEMORY_APP_TAG));
cJSON_AddItemToArray(tags, app_tag);
nostr_publish_result_t publish_result;
memset(&publish_result, 0, sizeof(publish_result));
int rc = nostr_handler_publish_kind_event(MEMORY_KIND, ciphertext, tags, &publish_result);
cJSON_Delete(tags);
free(ciphertext);
if (rc != 0) {
free(merged);
nostr_handler_publish_result_free(&publish_result);
return json_error_local("memory_save publish failed");
}
pthread_mutex_lock(&g_memory_mutex);
memory_cache_set_locked_local(merged);
pthread_mutex_unlock(&g_memory_mutex);
cJSON* out = cJSON_CreateObject();
if (!out) {
free(merged);
nostr_handler_publish_result_free(&publish_result);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "d_tag", MEMORY_D_TAG);
cJSON_AddNumberToObject(out, "kind", MEMORY_KIND);
cJSON_AddNumberToObject(out, "entry_length", (double)strlen(merged));
cJSON_AddBoolToObject(out, "truncated", (entry_truncated || body_truncated) ? 1 : 0);
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(merged);
nostr_handler_publish_result_free(&publish_result);
return json;
}
char* execute_memory_recall(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
pthread_mutex_lock(&g_memory_mutex);
int loaded = g_memory_loaded;
pthread_mutex_unlock(&g_memory_mutex);
if (!loaded) {
(void)memory_load_local(ctx);
}
pthread_mutex_lock(&g_memory_mutex);
char* memory = memory_cache_get_copy_locked_local();
pthread_mutex_unlock(&g_memory_mutex);
if (!memory) return NULL;
cJSON* out = cJSON_CreateObject();
if (!out) {
free(memory);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "content", memory);
cJSON_AddNumberToObject(out, "content_length", (double)strlen(memory));
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(memory);
return json;
}
char* execute_my_memory(tools_context_t* ctx, const char* args_json) {
return execute_memory_recall(ctx, args_json);
}
char* execute_memory_short_term_read(tools_context_t* ctx, const char* args_json) {
(void)ctx;
(void)args_json;
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "content", "");
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}

View File

@@ -370,6 +370,13 @@ static int adoption_tags_contains_address_local(cJSON* adoption_tags,
return 0;
}
static const char* skill_owner_label_local(const didactyl_config_t* cfg, const char* pubkey_hex) {
if (!cfg || !pubkey_hex || pubkey_hex[0] == '\0') return "unknown";
if (strcmp(pubkey_hex, cfg->keys.public_key_hex) == 0) return "agent";
if (cfg->admin.pubkey[0] != '\0' && strcmp(pubkey_hex, cfg->admin.pubkey) == 0) return "admin";
return "external";
}
static cJSON* extract_skill_summary_local(cJSON* event) {
if (!event || !cJSON_IsObject(event)) return NULL;
@@ -384,10 +391,15 @@ static cJSON* extract_skill_summary_local(cJSON* event) {
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (kind && cJSON_IsNumber(kind)) {
cJSON_AddNumberToObject(summary, "kind", kind->valuedouble);
cJSON_AddNumberToObject(summary, "kind", (int)kind->valuedouble);
}
if (created_at && cJSON_IsNumber(created_at)) {
cJSON_AddNumberToObject(summary, "created_at", created_at->valuedouble);
char ts_buf[32];
snprintf(ts_buf, sizeof(ts_buf), "%lld", (long long)created_at->valuedouble);
cJSON* ts_raw = cJSON_CreateRaw(ts_buf);
if (ts_raw) {
cJSON_AddItemToObject(summary, "created_at", ts_raw);
}
}
if (id && cJSON_IsString(id) && id->valuestring) {
cJSON_AddStringToObject(summary, "id", id->valuestring);
@@ -447,6 +459,11 @@ char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
cJSON* filter = cJSON_GetObjectItemCaseSensitive(args, "filter");
cJSON* action = cJSON_GetObjectItemCaseSensitive(args, "action");
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(args, "enabled");
cJSON* llm = cJSON_GetObjectItemCaseSensitive(args, "llm");
cJSON* tools = cJSON_GetObjectItemCaseSensitive(args, "tools");
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(args, "max_tokens");
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(args, "temperature");
cJSON* seed = cJSON_GetObjectItemCaseSensitive(args, "seed");
if (!d_tag || !cJSON_IsString(d_tag) || !d_tag->valuestring ||
!content || !cJSON_IsString(content) || !content->valuestring) {
@@ -482,6 +499,26 @@ char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
cJSON_Delete(args);
return json_error_local("skill_create enabled must be boolean when provided");
}
if (llm && (!cJSON_IsString(llm) || !llm->valuestring)) {
cJSON_Delete(args);
return json_error_local("skill_create llm must be string when provided");
}
if (tools && (!cJSON_IsString(tools) || !tools->valuestring)) {
cJSON_Delete(args);
return json_error_local("skill_create tools must be string when provided");
}
if (max_tokens && !cJSON_IsNumber(max_tokens)) {
cJSON_Delete(args);
return json_error_local("skill_create max_tokens must be number when provided");
}
if (temperature && !cJSON_IsNumber(temperature)) {
cJSON_Delete(args);
return json_error_local("skill_create temperature must be number when provided");
}
if (seed && !cJSON_IsNumber(seed)) {
cJSON_Delete(args);
return json_error_local("skill_create seed must be number when provided");
}
const char* scope_str = (scope && scope->valuestring && scope->valuestring[0] != '\0') ? scope->valuestring : "public";
int kind = 0;
@@ -533,10 +570,11 @@ char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
strcmp(trigger_str, "nostr-subscription") != 0 &&
strcmp(trigger_str, "webhook") != 0 &&
strcmp(trigger_str, "cron") != 0 &&
strcmp(trigger_str, "chain") != 0) {
strcmp(trigger_str, "chain") != 0 &&
strcmp(trigger_str, "dm") != 0) {
cJSON_Delete(tags);
cJSON_Delete(args);
return json_error_local("skill_create trigger must be one of: nostr-subscription, webhook, cron, chain");
return json_error_local("skill_create trigger must be one of: nostr-subscription, webhook, cron, chain, dm");
}
if ((trigger_str && !filter_str) || (!trigger_str && filter_str)) {
cJSON_Delete(tags);
@@ -552,6 +590,50 @@ char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
cJSON_Delete(args);
return json_error_local("failed to add trigger tags");
}
if (llm && llm->valuestring && llm->valuestring[0] != '\0' &&
add_string_tag_local(tags, "llm", llm->valuestring) != 0) {
cJSON_Delete(tags);
cJSON_Delete(args);
return json_error_local("failed to add llm trigger tag");
}
if (tools && tools->valuestring && tools->valuestring[0] != '\0' &&
add_string_tag_local(tags, "tools", tools->valuestring) != 0) {
cJSON_Delete(tags);
cJSON_Delete(args);
return json_error_local("failed to add tools trigger tag");
}
if (max_tokens) {
char buf[32];
snprintf(buf, sizeof(buf), "%d", (int)max_tokens->valuedouble);
if (add_string_tag_local(tags, "max_tokens", buf) != 0) {
cJSON_Delete(tags);
cJSON_Delete(args);
return json_error_local("failed to add max_tokens trigger tag");
}
}
if (temperature) {
char buf[32];
snprintf(buf, sizeof(buf), "%g", temperature->valuedouble);
if (add_string_tag_local(tags, "temperature", buf) != 0) {
cJSON_Delete(tags);
cJSON_Delete(args);
return json_error_local("failed to add temperature trigger tag");
}
}
if (seed) {
char buf[32];
snprintf(buf, sizeof(buf), "%d", (int)seed->valuedouble);
if (add_string_tag_local(tags, "seed", buf) != 0) {
cJSON_Delete(tags);
cJSON_Delete(args);
return json_error_local("failed to add seed trigger tag");
}
}
}
nostr_publish_result_t skill_publish;
@@ -614,7 +696,15 @@ char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
filter_str,
TRIGGER_ACTION_LLM,
trigger_str,
enabled_int) == 0) {
enabled_int,
(llm && llm->valuestring) ? llm->valuestring : NULL,
(tools && tools->valuestring) ? tools->valuestring : NULL,
max_tokens ? 1 : 0,
max_tokens ? (int)max_tokens->valuedouble : 0,
temperature ? 1 : 0,
temperature ? temperature->valuedouble : 0.0,
seed ? 1 : 0,
seed ? (int)seed->valuedouble : 0) == 0) {
trigger_registered = 1;
}
}
@@ -659,10 +749,15 @@ char* execute_skill_list(tools_context_t* ctx, const char* args_json) {
if (!args) return json_error_local("invalid arguments JSON");
cJSON* scope = cJSON_GetObjectItemCaseSensitive(args, "scope");
cJSON* adopted = cJSON_GetObjectItemCaseSensitive(args, "adopted");
if (scope && (!cJSON_IsString(scope) || !scope->valuestring)) {
cJSON_Delete(args);
return json_error_local("skill_list scope must be string when provided");
}
if (adopted && !cJSON_IsBool(adopted)) {
cJSON_Delete(args);
return json_error_local("skill_list adopted must be boolean when provided");
}
const char* scope_str = (scope && scope->valuestring && scope->valuestring[0] != '\0') ? scope->valuestring : NULL;
int include_public = 1;
@@ -677,6 +772,11 @@ char* execute_skill_list(tools_context_t* ctx, const char* args_json) {
return json_error_local("skill_list scope must be public or private");
}
}
int adopted_filter = -1;
if (adopted) {
adopted_filter = cJSON_IsTrue(adopted) ? 1 : 0;
}
cJSON_Delete(args);
cJSON* adoption_tags = NULL;
@@ -702,9 +802,11 @@ char* execute_skill_list(tools_context_t* ctx, const char* args_json) {
}
cJSON* out = cJSON_CreateObject();
cJSON* summary_rows = cJSON_CreateArray();
cJSON* skills = cJSON_CreateArray();
if (!out || !skills) {
if (!out || !summary_rows || !skills) {
cJSON_Delete(out);
cJSON_Delete(summary_rows);
cJSON_Delete(skills);
cJSON_Delete(events);
cJSON_Delete(adoption_tags);
@@ -712,6 +814,7 @@ char* execute_skill_list(tools_context_t* ctx, const char* args_json) {
return NULL;
}
int adopted_count = 0;
int event_count = cJSON_GetArraySize(events);
for (int i = 0; i < event_count; i++) {
cJSON* ev = cJSON_GetArrayItem(events, i);
@@ -727,6 +830,9 @@ char* execute_skill_list(tools_context_t* ctx, const char* args_json) {
}
int kind_val = (int)kind->valuedouble;
if (kind_val != 31123 && kind_val != 31124) {
continue;
}
if ((kind_val == 31123 && !include_public) || (kind_val == 31124 && !include_private)) {
continue;
}
@@ -736,18 +842,63 @@ char* execute_skill_list(tools_context_t* ctx, const char* args_json) {
continue;
}
if (!adoption_tags_contains_address_local(adoption_tags, kind_val, pubkey->valuestring, d->valuestring)) {
int is_adopted = adoption_tags_contains_address_local(adoption_tags, kind_val, pubkey->valuestring, d->valuestring) ? 1 : 0;
if (adopted_filter >= 0 && is_adopted != adopted_filter) {
continue;
}
if (is_adopted) {
adopted_count++;
}
cJSON* summary = extract_skill_summary_local(ev);
if (summary) cJSON_AddItemToArray(skills, summary);
const char* owner = skill_owner_label_local(ctx->cfg, pubkey->valuestring);
cJSON* detail = extract_skill_summary_local(ev);
if (detail) {
cJSON_AddStringToObject(detail, "owner", owner);
cJSON_AddBoolToObject(detail, "adopted", is_adopted ? 1 : 0);
cJSON_AddItemToArray(skills, detail);
}
cJSON* row = cJSON_CreateObject();
if (row) {
cJSON_AddStringToObject(row, "d_tag", d->valuestring);
cJSON_AddStringToObject(row, "owner", owner);
cJSON_AddBoolToObject(row, "adopted", is_adopted ? 1 : 0);
cJSON_AddItemToArray(summary_rows, row);
}
}
cJSON* skills_pretty = cJSON_CreateArray();
if (!skills_pretty) {
cJSON_Delete(out);
cJSON_Delete(events);
cJSON_Delete(adoption_tags);
free(adoption_content);
return NULL;
}
int skill_count = cJSON_GetArraySize(skills);
for (int i = 0; i < skill_count; i++) {
cJSON* skill_item = cJSON_GetArrayItem(skills, i);
if (!skill_item) continue;
char* skill_json = cJSON_PrintUnformatted(skill_item);
if (!skill_json) continue;
cJSON* block = cJSON_CreateString(skill_json);
free(skill_json);
if (block) {
cJSON_AddItemToArray(skills_pretty, block);
}
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddBoolToObject(out, "fallback_used", 0);
cJSON_AddStringToObject(out, "skills_separator", "\n\n\n");
cJSON_AddItemToObject(out, "summary", summary_rows);
cJSON_AddItemToObject(out, "skills", skills);
cJSON_AddItemToObject(out, "skills_pretty", skills_pretty);
cJSON_AddNumberToObject(out, "count", cJSON_GetArraySize(skills));
cJSON_AddNumberToObject(out, "adopted_count", adopted_count);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
@@ -961,6 +1112,11 @@ char* execute_skill_edit(tools_context_t* ctx, const char* args_json) {
cJSON* filter = cJSON_GetObjectItemCaseSensitive(args, "filter");
cJSON* action = cJSON_GetObjectItemCaseSensitive(args, "action");
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(args, "enabled");
cJSON* llm = cJSON_GetObjectItemCaseSensitive(args, "llm");
cJSON* tools = cJSON_GetObjectItemCaseSensitive(args, "tools");
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(args, "max_tokens");
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(args, "temperature");
cJSON* seed = cJSON_GetObjectItemCaseSensitive(args, "seed");
if (!d || !cJSON_IsString(d) || !d->valuestring || !validate_skill_d_tag_local(d->valuestring)) {
cJSON_Delete(args);
@@ -994,6 +1150,26 @@ char* execute_skill_edit(tools_context_t* ctx, const char* args_json) {
cJSON_Delete(args);
return json_error_local("skill_edit enabled must be boolean when provided");
}
if (llm && (!cJSON_IsString(llm) || !llm->valuestring)) {
cJSON_Delete(args);
return json_error_local("skill_edit llm must be string when provided");
}
if (tools && (!cJSON_IsString(tools) || !tools->valuestring)) {
cJSON_Delete(args);
return json_error_local("skill_edit tools must be string when provided");
}
if (max_tokens && !cJSON_IsNumber(max_tokens)) {
cJSON_Delete(args);
return json_error_local("skill_edit max_tokens must be number when provided");
}
if (temperature && !cJSON_IsNumber(temperature)) {
cJSON_Delete(args);
return json_error_local("skill_edit temperature must be number when provided");
}
if (seed && !cJSON_IsNumber(seed)) {
cJSON_Delete(args);
return json_error_local("skill_edit seed must be number when provided");
}
char* events_json = nostr_handler_get_self_skill_events_json();
if (!events_json) {
@@ -1124,7 +1300,7 @@ char* execute_skill_edit(tools_context_t* ctx, const char* args_json) {
? 0
: 1;
int trigger_patch_requested = (trigger || filter || action || enabled) ? 1 : 0;
int trigger_patch_requested = (trigger || filter || action || enabled || llm || tools || max_tokens || temperature || seed) ? 1 : 0;
if (trigger_patch_requested || (merged_trigger || merged_filter)) {
if ((merged_trigger && !merged_filter) || (!merged_trigger && merged_filter)) {
cJSON_Delete(args);
@@ -1141,11 +1317,12 @@ char* execute_skill_edit(tools_context_t* ctx, const char* args_json) {
if (strcmp(merged_trigger, "nostr-subscription") != 0 &&
strcmp(merged_trigger, "webhook") != 0 &&
strcmp(merged_trigger, "cron") != 0 &&
strcmp(merged_trigger, "chain") != 0) {
strcmp(merged_trigger, "chain") != 0 &&
strcmp(merged_trigger, "dm") != 0) {
cJSON_Delete(args);
cJSON_Delete(events);
cJSON_Delete(tags_out);
return json_error_local("skill_edit trigger must be one of: nostr-subscription, webhook, cron, chain");
return json_error_local("skill_edit trigger must be one of: nostr-subscription, webhook, cron, chain, dm");
}
if (add_string_tag_local(tags_out, "trigger", merged_trigger) != 0 ||
add_string_tag_local(tags_out, "filter", merged_filter) != 0 ||
@@ -1156,6 +1333,51 @@ char* execute_skill_edit(tools_context_t* ctx, const char* args_json) {
cJSON_Delete(tags_out);
return json_error_local("skill_edit failed to set trigger tags");
}
if (llm && llm->valuestring && llm->valuestring[0] != '\0' &&
add_string_tag_local(tags_out, "llm", llm->valuestring) != 0) {
cJSON_Delete(args);
cJSON_Delete(events);
cJSON_Delete(tags_out);
return json_error_local("skill_edit failed to set llm tag");
}
if (tools && tools->valuestring && tools->valuestring[0] != '\0' &&
add_string_tag_local(tags_out, "tools", tools->valuestring) != 0) {
cJSON_Delete(args);
cJSON_Delete(events);
cJSON_Delete(tags_out);
return json_error_local("skill_edit failed to set tools tag");
}
if (max_tokens) {
char buf[32];
snprintf(buf, sizeof(buf), "%d", (int)max_tokens->valuedouble);
if (add_string_tag_local(tags_out, "max_tokens", buf) != 0) {
cJSON_Delete(args);
cJSON_Delete(events);
cJSON_Delete(tags_out);
return json_error_local("skill_edit failed to set max_tokens tag");
}
}
if (temperature) {
char buf[32];
snprintf(buf, sizeof(buf), "%g", temperature->valuedouble);
if (add_string_tag_local(tags_out, "temperature", buf) != 0) {
cJSON_Delete(args);
cJSON_Delete(events);
cJSON_Delete(tags_out);
return json_error_local("skill_edit failed to set temperature tag");
}
}
if (seed) {
char buf[32];
snprintf(buf, sizeof(buf), "%d", (int)seed->valuedouble);
if (add_string_tag_local(tags_out, "seed", buf) != 0) {
cJSON_Delete(args);
cJSON_Delete(events);
cJSON_Delete(tags_out);
return json_error_local("skill_edit failed to set seed tag");
}
}
}
}
@@ -1183,14 +1405,30 @@ char* execute_skill_edit(tools_context_t* ctx, const char* args_json) {
merged_filter,
TRIGGER_ACTION_LLM,
merged_trigger,
merged_enabled) == 0 ||
merged_enabled,
(llm && llm->valuestring) ? llm->valuestring : NULL,
(tools && tools->valuestring) ? tools->valuestring : NULL,
max_tokens ? 1 : 0,
max_tokens ? (int)max_tokens->valuedouble : 0,
temperature ? 1 : 0,
temperature ? temperature->valuedouble : 0.0,
seed ? 1 : 0,
seed ? (int)seed->valuedouble : 0) == 0 ||
trigger_manager_add(ctx->trigger_manager,
d->valuestring,
out_content,
merged_filter,
TRIGGER_ACTION_LLM,
merged_trigger,
merged_enabled) == 0) {
merged_enabled,
(llm && llm->valuestring) ? llm->valuestring : NULL,
(tools && tools->valuestring) ? tools->valuestring : NULL,
max_tokens ? 1 : 0,
max_tokens ? (int)max_tokens->valuedouble : 0,
temperature ? 1 : 0,
temperature ? temperature->valuedouble : 0.0,
seed ? 1 : 0,
seed ? (int)seed->valuedouble : 0) == 0) {
trigger_registered = 1;
}
} else {

View File

@@ -10,6 +10,7 @@ typedef struct {
struct trigger_manager* trigger_manager;
int template_sender_tier;
const char* template_current_user_message;
const char* template_trigger_event_json;
} tools_context_t;
int tools_init(tools_context_t* ctx, didactyl_config_t* cfg);

View File

@@ -24,6 +24,7 @@ int tools_init_legacy(tools_context_t* ctx, didactyl_config_t* cfg) {
ctx->trigger_manager = NULL;
ctx->template_sender_tier = 0;
ctx->template_current_user_message = NULL;
ctx->template_trigger_event_json = NULL;
return 0;
}
@@ -198,15 +199,46 @@ char* tools_execute_legacy(tools_context_t* ctx, const char* tool_name, const ch
if (strcmp(tool_name, "my_notes") == 0) {
return execute_nostr_agent_notes(ctx, args_json);
}
if (strcmp(tool_name, "memory_save") == 0) {
return execute_memory_save(ctx, args_json);
}
if (strcmp(tool_name, "memory_recall") == 0) {
return execute_memory_recall(ctx, args_json);
}
if (strcmp(tool_name, "my_memory") == 0) {
return execute_my_memory(ctx, args_json);
}
if (strcmp(tool_name, "memory_short_term_read") == 0) {
return execute_memory_short_term_read(ctx, args_json);
}
if (strcmp(tool_name, "config_store") == 0) {
return execute_config_store(ctx, args_json);
}
if (strcmp(tool_name, "config_recall") == 0) {
return execute_config_recall(ctx, args_json);
}
if (strcmp(tool_name, "adopted_skills") == 0) {
return execute_adopted_skills(ctx, args_json);
}
if (strcmp(tool_name, "trigger_event") == 0) {
return execute_trigger_event(ctx, args_json);
}
return json_error("unknown tool");
}
int tools_init(tools_context_t* ctx, didactyl_config_t* cfg) {
return tools_init_legacy(ctx, cfg);
int rc = tools_init_legacy(ctx, cfg);
if (rc != 0) return rc;
if (memory_init(ctx) != 0) {
/* Keep agent startup resilient: memory may be empty or temporarily unavailable. */
return 0;
}
return 0;
}
void tools_cleanup(tools_context_t* ctx) {
memory_cleanup();
tools_cleanup_legacy(ctx);
}

View File

@@ -57,4 +57,15 @@ char* execute_skill_remove(tools_context_t* ctx, const char* args_json);
char* execute_skill_edit(tools_context_t* ctx, const char* args_json);
char* execute_skill_search(const char* args_json);
char* execute_memory_save(tools_context_t* ctx, const char* args_json);
char* execute_memory_recall(tools_context_t* ctx, const char* args_json);
char* execute_my_memory(tools_context_t* ctx, const char* args_json);
char* execute_memory_short_term_read(tools_context_t* ctx, const char* args_json);
char* execute_config_store(tools_context_t* ctx, const char* args_json);
char* execute_config_recall(tools_context_t* ctx, const char* args_json);
char* execute_adopted_skills(tools_context_t* ctx, const char* args_json);
char* execute_trigger_event(tools_context_t* ctx, const char* args_json);
int memory_init(tools_context_t* ctx);
void memory_cleanup(void);
#endif

View File

@@ -637,6 +637,26 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON_AddStringToObject(p_skill_create_enabled, "type", "boolean");
cJSON_AddItemToObject(t22_props, "enabled", p_skill_create_enabled);
cJSON* p_skill_create_llm = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_create_llm, "type", "string");
cJSON_AddItemToObject(t22_props, "llm", p_skill_create_llm);
cJSON* p_skill_create_tools = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_create_tools, "type", "string");
cJSON_AddItemToObject(t22_props, "tools", p_skill_create_tools);
cJSON* p_skill_create_max_tokens = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_create_max_tokens, "type", "integer");
cJSON_AddItemToObject(t22_props, "max_tokens", p_skill_create_max_tokens);
cJSON* p_skill_create_temperature = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_create_temperature, "type", "number");
cJSON_AddItemToObject(t22_props, "temperature", p_skill_create_temperature);
cJSON* p_skill_create_seed = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_create_seed, "type", "integer");
cJSON_AddItemToObject(t22_props, "seed", p_skill_create_seed);
cJSON_AddItemToArray(t22_required, cJSON_CreateString("d"));
cJSON_AddItemToArray(t22_required, cJSON_CreateString("content"));
@@ -651,13 +671,16 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON_AddStringToObject(t23, "type", "function");
cJSON_AddStringToObject(t23_fn, "name", "skill_list");
cJSON_AddStringToObject(t23_fn, "description", "List this agent's published skills, optionally filtered by scope");
cJSON_AddStringToObject(t23_fn, "description", "List available skills discovered online (agent + admin), with adoption status and optional filters");
cJSON_AddStringToObject(t23_params, "type", "object");
cJSON_AddItemToObject(t23_params, "properties", t23_props);
cJSON* p_skill_list_scope = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_list_scope, "type", "string");
cJSON_AddItemToObject(t23_props, "scope", p_skill_list_scope);
cJSON* p_skill_list_adopted = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_list_adopted, "type", "boolean");
cJSON_AddItemToObject(t23_props, "adopted", p_skill_list_adopted);
cJSON_AddItemToObject(t23_fn, "parameters", t23_params);
cJSON_AddItemToObject(t23, "function", t23_fn);
@@ -767,6 +790,26 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON_AddStringToObject(p_skill_edit_enabled, "type", "boolean");
cJSON_AddItemToObject(t25b_props, "enabled", p_skill_edit_enabled);
cJSON* p_skill_edit_llm = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_edit_llm, "type", "string");
cJSON_AddItemToObject(t25b_props, "llm", p_skill_edit_llm);
cJSON* p_skill_edit_tools = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_edit_tools, "type", "string");
cJSON_AddItemToObject(t25b_props, "tools", p_skill_edit_tools);
cJSON* p_skill_edit_max_tokens = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_edit_max_tokens, "type", "integer");
cJSON_AddItemToObject(t25b_props, "max_tokens", p_skill_edit_max_tokens);
cJSON* p_skill_edit_temperature = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_edit_temperature, "type", "number");
cJSON_AddItemToObject(t25b_props, "temperature", p_skill_edit_temperature);
cJSON* p_skill_edit_seed = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_edit_seed, "type", "integer");
cJSON_AddItemToObject(t25b_props, "seed", p_skill_edit_seed);
cJSON_AddItemToArray(t25b_required, cJSON_CreateString("d"));
cJSON_AddItemToObject(t25b_fn, "parameters", t25b_params);
@@ -1261,6 +1304,95 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON_AddItemToObject(t45, "function", t45_fn);
cJSON_AddItemToArray(tools, t45);
cJSON* t46 = cJSON_CreateObject();
cJSON* t46_fn = cJSON_CreateObject();
cJSON* t46_params = cJSON_CreateObject();
cJSON* t46_props = cJSON_CreateObject();
cJSON* t46_required = cJSON_CreateArray();
cJSON_AddStringToObject(t46, "type", "function");
cJSON_AddStringToObject(t46_fn, "name", "memory_save");
cJSON_AddStringToObject(t46_fn, "description", "Prepend a new entry to encrypted agent memory (kind 30078, d=memory) and truncate oldest content if needed");
cJSON_AddStringToObject(t46_params, "type", "object");
cJSON_AddItemToObject(t46_params, "properties", t46_props);
cJSON_AddItemToObject(t46_params, "required", t46_required);
cJSON* p_mem_content = cJSON_CreateObject();
cJSON_AddStringToObject(p_mem_content, "type", "string");
cJSON_AddItemToObject(t46_props, "content", p_mem_content);
cJSON_AddItemToArray(t46_required, cJSON_CreateString("content"));
cJSON_AddItemToObject(t46_fn, "parameters", t46_params);
cJSON_AddItemToObject(t46, "function", t46_fn);
cJSON_AddItemToArray(tools, t46);
cJSON* t47 = cJSON_CreateObject();
cJSON* t47_fn = cJSON_CreateObject();
cJSON* t47_params = cJSON_CreateObject();
cJSON* t47_props = cJSON_CreateObject();
cJSON_AddStringToObject(t47, "type", "function");
cJSON_AddStringToObject(t47_fn, "name", "memory_recall");
cJSON_AddStringToObject(t47_fn, "description", "Recall encrypted agent memory (kind 30078, d=memory)");
cJSON_AddStringToObject(t47_params, "type", "object");
cJSON_AddItemToObject(t47_params, "properties", t47_props);
cJSON_AddItemToObject(t47_fn, "parameters", t47_params);
cJSON_AddItemToObject(t47, "function", t47_fn);
cJSON_AddItemToArray(tools, t47);
cJSON* t48 = cJSON_CreateObject();
cJSON* t48_fn = cJSON_CreateObject();
cJSON* t48_params = cJSON_CreateObject();
cJSON* t48_props = cJSON_CreateObject();
cJSON* t48_required = cJSON_CreateArray();
cJSON_AddStringToObject(t48, "type", "function");
cJSON_AddStringToObject(t48_fn, "name", "config_store");
cJSON_AddStringToObject(t48_fn, "description", "Encrypt and publish agent config as kind 30078 for a given d_tag");
cJSON_AddStringToObject(t48_params, "type", "object");
cJSON_AddItemToObject(t48_params, "properties", t48_props);
cJSON_AddItemToObject(t48_params, "required", t48_required);
cJSON* p_cfg_store_d = cJSON_CreateObject();
cJSON_AddStringToObject(p_cfg_store_d, "type", "string");
cJSON_AddItemToObject(t48_props, "d_tag", p_cfg_store_d);
cJSON* p_cfg_store_content = cJSON_CreateObject();
cJSON_AddStringToObject(p_cfg_store_content, "description", "Arbitrary JSON value or string to encrypt and store");
cJSON_AddItemToObject(t48_props, "content", p_cfg_store_content);
cJSON_AddItemToArray(t48_required, cJSON_CreateString("d_tag"));
cJSON_AddItemToArray(t48_required, cJSON_CreateString("content"));
cJSON_AddItemToObject(t48_fn, "parameters", t48_params);
cJSON_AddItemToObject(t48, "function", t48_fn);
cJSON_AddItemToArray(tools, t48);
cJSON* t49 = cJSON_CreateObject();
cJSON* t49_fn = cJSON_CreateObject();
cJSON* t49_params = cJSON_CreateObject();
cJSON* t49_props = cJSON_CreateObject();
cJSON* t49_required = cJSON_CreateArray();
cJSON_AddStringToObject(t49, "type", "function");
cJSON_AddStringToObject(t49_fn, "name", "config_recall");
cJSON_AddStringToObject(t49_fn, "description", "Fetch and decrypt agent config kind 30078 by d_tag");
cJSON_AddStringToObject(t49_params, "type", "object");
cJSON_AddItemToObject(t49_params, "properties", t49_props);
cJSON_AddItemToObject(t49_params, "required", t49_required);
cJSON* p_cfg_recall_d = cJSON_CreateObject();
cJSON_AddStringToObject(p_cfg_recall_d, "type", "string");
cJSON_AddItemToObject(t49_props, "d_tag", p_cfg_recall_d);
cJSON_AddItemToArray(t49_required, cJSON_CreateString("d_tag"));
cJSON_AddItemToObject(t49_fn, "parameters", t49_params);
cJSON_AddItemToObject(t49, "function", t49_fn);
cJSON_AddItemToArray(tools, t49);
char* out = cJSON_PrintUnformatted(tools);
cJSON_Delete(tools);
return out;

View File

@@ -8,10 +8,15 @@
#include <time.h>
#include <ctype.h>
#include "agent.h"
#include "cjson/cJSON.h"
#include "debug.h"
#include "nostr_handler.h"
#include "llm.h"
void agent_on_trigger(const char* skill_d_tag,
const char* skill_content,
cJSON* triggering_event,
const char* relay_url);
typedef struct {
trigger_manager_t* mgr;
@@ -35,6 +40,9 @@ trigger_type_t trigger_type_from_string(const char* s) {
if (strcmp(s, "chain") == 0) {
return TRIGGER_TYPE_CHAIN;
}
if (strcmp(s, "dm") == 0) {
return TRIGGER_TYPE_DM;
}
return TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
}
@@ -46,6 +54,8 @@ const char* trigger_type_to_string(trigger_type_t t) {
return "cron";
case TRIGGER_TYPE_CHAIN:
return "chain";
case TRIGGER_TYPE_DM:
return "dm";
case TRIGGER_TYPE_NOSTR_SUBSCRIPTION:
default:
return "nostr-subscription";
@@ -327,11 +337,127 @@ static int parse_address_tag(const char* addr, int* out_kind, char out_pubkey[65
return 0;
}
static const char* trim_ws_local(const char* s) {
while (s && *s && isspace((unsigned char)*s)) s++;
return s ? s : "";
}
static void parse_trigger_runtime_tags(cJSON* tags,
const char** out_llm,
const char** out_tools,
int* out_has_max_tokens,
int* out_max_tokens,
int* out_has_temperature,
double* out_temperature,
int* out_has_seed,
int* out_seed) {
if (out_llm) *out_llm = NULL;
if (out_tools) *out_tools = NULL;
if (out_has_max_tokens) *out_has_max_tokens = 0;
if (out_max_tokens) *out_max_tokens = 0;
if (out_has_temperature) *out_has_temperature = 0;
if (out_temperature) *out_temperature = 0.0;
if (out_has_seed) *out_has_seed = 0;
if (out_seed) *out_seed = 0;
if (!tags || !cJSON_IsArray(tags)) {
return;
}
cJSON* llm = find_tag_value_string(tags, "llm");
cJSON* tools = find_tag_value_string(tags, "tools");
cJSON* max_tokens = find_tag_value_string(tags, "max_tokens");
cJSON* temperature = find_tag_value_string(tags, "temperature");
cJSON* seed = find_tag_value_string(tags, "seed");
if (out_llm && llm && cJSON_IsString(llm) && llm->valuestring && llm->valuestring[0] != '\0') {
*out_llm = llm->valuestring;
}
if (out_tools && tools && cJSON_IsString(tools) && tools->valuestring && tools->valuestring[0] != '\0') {
*out_tools = tools->valuestring;
}
if (out_has_max_tokens && out_max_tokens && max_tokens && cJSON_IsString(max_tokens) && max_tokens->valuestring) {
char* endptr = NULL;
long n = strtol(trim_ws_local(max_tokens->valuestring), &endptr, 10);
if (endptr && *trim_ws_local(endptr) == '\0' && n > 0 && n <= 1000000) {
*out_has_max_tokens = 1;
*out_max_tokens = (int)n;
}
}
if (out_has_temperature && out_temperature && temperature && cJSON_IsString(temperature) && temperature->valuestring) {
char* endptr = NULL;
double n = strtod(trim_ws_local(temperature->valuestring), &endptr);
if (endptr && *trim_ws_local(endptr) == '\0' && n >= 0.0 && n <= 2.0) {
*out_has_temperature = 1;
*out_temperature = n;
}
}
if (out_has_seed && out_seed && seed && cJSON_IsString(seed) && seed->valuestring) {
char* endptr = NULL;
long n = strtol(trim_ws_local(seed->valuestring), &endptr, 10);
if (endptr && *trim_ws_local(endptr) == '\0') {
*out_has_seed = 1;
*out_seed = (int)n;
}
}
}
static void apply_trigger_runtime_to_llm_config(const active_trigger_t* t, llm_config_t* cfg) {
if (!t || !cfg) return;
if (t->llm_spec[0] != '\0') {
const char* spec = t->llm_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) {
const char* model = slash + 1;
size_t model_len = len - (size_t)(model - spec);
if (model_len > 0 && model_len < sizeof(cfg->model)) {
snprintf(cfg->model, sizeof(cfg->model), "%.*s", (int)model_len, model);
}
} else if (len > 0 && len < sizeof(cfg->model)) {
snprintf(cfg->model, sizeof(cfg->model), "%.*s", (int)len, spec);
}
}
if (t->has_max_tokens && t->max_tokens > 0) {
cfg->max_tokens = t->max_tokens;
}
if (t->has_temperature) {
cfg->temperature = t->temperature;
}
}
static void execute_llm_action(const active_trigger_t* t, cJSON* event, const char* relay_url) {
if (!t || !event) {
return;
}
llm_config_t old_cfg;
int had_old_cfg = (llm_get_config(&old_cfg) == 0);
int overridden = 0;
if (had_old_cfg &&
(t->llm_spec[0] != '\0' || t->has_max_tokens || t->has_temperature)) {
llm_config_t next = old_cfg;
apply_trigger_runtime_to_llm_config(t, &next);
if (llm_set_config(&next) == 0) {
overridden = 1;
}
}
agent_on_trigger(t->skill_d_tag, t->skill_content, event, relay_url);
if (overridden) {
(void)llm_set_config(&old_cfg);
}
}
static int maybe_fire_trigger_locked(trigger_manager_t* mgr, int index, cJSON* event, const char* relay_url) {
@@ -622,13 +748,46 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
(strcmp(trigger_s, "nostr-subscription") == 0 ||
strcmp(trigger_s, "webhook") == 0 ||
strcmp(trigger_s, "cron") == 0 ||
strcmp(trigger_s, "chain") == 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;
int max_tokens = 0;
int has_temperature = 0;
double temperature = 0.0;
int has_seed = 0;
int seed = 0;
parse_trigger_runtime_tags(tags,
&llm_s,
&tools_s,
&has_max_tokens,
&max_tokens,
&has_temperature,
&temperature,
&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) == 0) {
if (trigger_manager_add(mgr,
d_tag,
content->valuestring,
filter_s,
TRIGGER_ACTION_LLM,
trigger_s,
is_enabled,
llm_s,
tools_s,
has_max_tokens,
max_tokens,
has_temperature,
temperature,
has_seed,
seed) == 0) {
loaded++;
}
}
@@ -694,7 +853,8 @@ int trigger_manager_load_from_startup_events(trigger_manager_t* mgr) {
(strcmp(trigger_s, "nostr-subscription") == 0 ||
strcmp(trigger_s, "webhook") == 0 ||
strcmp(trigger_s, "cron") == 0 ||
strcmp(trigger_s, "chain") == 0);
strcmp(trigger_s, "chain") == 0 ||
strcmp(trigger_s, "dm") == 0);
if (!trigger_supported || !filter_s || filter_s[0] == '\0') {
cJSON_Delete(tags);
continue;
@@ -705,7 +865,39 @@ int trigger_manager_load_from_startup_events(trigger_manager_t* mgr) {
}
int is_enabled = (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1;
if (trigger_manager_add(mgr, d_tag, ev->content, filter_s, TRIGGER_ACTION_LLM, trigger_s, is_enabled) == 0) {
const char* llm_s = NULL;
const char* tools_s = NULL;
int has_max_tokens = 0;
int max_tokens = 0;
int has_temperature = 0;
double temperature = 0.0;
int has_seed = 0;
int seed = 0;
parse_trigger_runtime_tags(tags,
&llm_s,
&tools_s,
&has_max_tokens,
&max_tokens,
&has_temperature,
&temperature,
&has_seed,
&seed);
if (trigger_manager_add(mgr,
d_tag,
ev->content,
filter_s,
TRIGGER_ACTION_LLM,
trigger_s,
is_enabled,
llm_s,
tools_s,
has_max_tokens,
max_tokens,
has_temperature,
temperature,
has_seed,
seed) == 0) {
loaded++;
DEBUG_INFO("[didactyl] startup trigger registered d_tag=%s action=%s enabled=%d", d_tag, "llm", is_enabled);
} else {
@@ -725,7 +917,15 @@ int trigger_manager_add(trigger_manager_t* mgr,
const char* filter_json,
trigger_action_type_t action_type,
const char* trigger_type_str,
int enabled) {
int enabled,
const char* llm_spec,
const char* tools_policy,
int has_max_tokens,
int max_tokens,
int has_temperature,
double temperature,
int has_seed,
int seed) {
if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0' || !content || !filter_json) {
return -1;
}
@@ -743,7 +943,21 @@ int trigger_manager_add(trigger_manager_t* mgr,
int existing = find_trigger_index_locked(mgr, skill_d_tag);
if (existing >= 0) {
pthread_mutex_unlock(&mgr->mutex);
return trigger_manager_update(mgr, skill_d_tag, content, filter_json, action_type, trigger_type_str, enabled);
return trigger_manager_update(mgr,
skill_d_tag,
content,
filter_json,
action_type,
trigger_type_str,
enabled,
llm_spec,
tools_policy,
has_max_tokens,
max_tokens,
has_temperature,
temperature,
has_seed,
seed);
}
int max_active = mgr->cfg ? mgr->cfg->triggers.max_active : TRIGGER_DEFAULT_MAX_ACTIVE;
@@ -773,6 +987,14 @@ int trigger_manager_add(trigger_manager_t* mgr,
if (t->trigger_type == TRIGGER_TYPE_CRON) {
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
}
snprintf(t->llm_spec, sizeof(t->llm_spec), "%s", llm_spec ? llm_spec : "");
snprintf(t->tools_policy, sizeof(t->tools_policy), "%s", tools_policy ? tools_policy : "");
t->has_max_tokens = has_max_tokens ? 1 : 0;
t->max_tokens = max_tokens;
t->has_temperature = has_temperature ? 1 : 0;
t->temperature = temperature;
t->has_seed = has_seed ? 1 : 0;
t->seed = seed;
t->subscription = NULL;
t->subscription_ctx = NULL;
@@ -936,7 +1158,15 @@ int trigger_manager_update(trigger_manager_t* mgr,
const char* filter_json,
trigger_action_type_t action_type,
const char* trigger_type_str,
int enabled) {
int enabled,
const char* llm_spec,
const char* tools_policy,
int has_max_tokens,
int max_tokens,
int has_temperature,
double temperature,
int has_seed,
int seed) {
if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0' || !content || !filter_json) {
return -1;
}
@@ -954,7 +1184,21 @@ int trigger_manager_update(trigger_manager_t* mgr,
int idx = find_trigger_index_locked(mgr, skill_d_tag);
if (idx < 0) {
pthread_mutex_unlock(&mgr->mutex);
return trigger_manager_add(mgr, skill_d_tag, content, filter_json, action_type, trigger_type_str, enabled);
return trigger_manager_add(mgr,
skill_d_tag,
content,
filter_json,
action_type,
trigger_type_str,
enabled,
llm_spec,
tools_policy,
has_max_tokens,
max_tokens,
has_temperature,
temperature,
has_seed,
seed);
}
active_trigger_t* t = &mgr->triggers[idx];
@@ -968,6 +1212,14 @@ int trigger_manager_update(trigger_manager_t* mgr,
} else {
t->cron_expr[0] = '\0';
}
snprintf(t->llm_spec, sizeof(t->llm_spec), "%s", llm_spec ? llm_spec : "");
snprintf(t->tools_policy, sizeof(t->tools_policy), "%s", tools_policy ? tools_policy : "");
t->has_max_tokens = has_max_tokens ? 1 : 0;
t->max_tokens = max_tokens;
t->has_temperature = has_temperature ? 1 : 0;
t->temperature = temperature;
t->has_seed = has_seed ? 1 : 0;
t->seed = seed;
if (register_trigger_subscription_locked(mgr, t) != 0) {
pthread_mutex_unlock(&mgr->mutex);
@@ -1059,6 +1311,79 @@ int trigger_manager_poll(trigger_manager_t* mgr) {
return fired;
}
static int dm_filter_matches_tier(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;
}
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) {
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;
pthread_mutex_lock(&mgr->mutex);
int count_snapshot = mgr->count;
for (int i = 0; i < count_snapshot; i++) {
active_trigger_t* t = &mgr->triggers[i];
if (!t->enabled || t->trigger_type != TRIGGER_TYPE_DM) {
continue;
}
if (!dm_filter_matches_tier(t->filter_json, tier)) {
continue;
}
int fired = maybe_fire_trigger_locked(mgr, i, event, source_label ? source_label : "dm");
if (fired > 0) {
fired_total += fired;
}
}
pthread_mutex_unlock(&mgr->mutex);
cJSON_Delete(event);
return fired_total;
}
char* trigger_manager_status_json(trigger_manager_t* mgr) {
if (!mgr) {
cJSON* err = cJSON_CreateObject();
@@ -1103,6 +1428,14 @@ char* trigger_manager_status_json(trigger_manager_t* mgr) {
cJSON_AddStringToObject(item, "action", "llm");
cJSON_AddBoolToObject(item, "enabled", t->enabled ? 1 : 0);
cJSON_AddBoolToObject(item, "subscribed", t->subscription ? 1 : 0);
cJSON_AddStringToObject(item, "llm", t->llm_spec);
cJSON_AddStringToObject(item, "tools", t->tools_policy);
cJSON_AddBoolToObject(item, "has_max_tokens", t->has_max_tokens ? 1 : 0);
if (t->has_max_tokens) cJSON_AddNumberToObject(item, "max_tokens", t->max_tokens);
cJSON_AddBoolToObject(item, "has_temperature", t->has_temperature ? 1 : 0);
if (t->has_temperature) cJSON_AddNumberToObject(item, "temperature", t->temperature);
cJSON_AddBoolToObject(item, "has_seed", t->has_seed ? 1 : 0);
if (t->has_seed) cJSON_AddNumberToObject(item, "seed", t->seed);
cJSON_AddNumberToObject(item, "last_fired", (double)t->last_fired);
cJSON_AddNumberToObject(item, "last_seen_created_at", (double)t->last_seen_created_at);
cJSON_AddItemToArray(arr, item);

View File

@@ -6,6 +6,7 @@
#include "config.h"
#include "cjson/cJSON.h"
#include "nostr_handler.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
#define TRIGGER_DEFAULT_MAX_ACTIVE 16
@@ -22,7 +23,8 @@ typedef enum {
TRIGGER_TYPE_NOSTR_SUBSCRIPTION = 0,
TRIGGER_TYPE_WEBHOOK = 1,
TRIGGER_TYPE_CRON = 2,
TRIGGER_TYPE_CHAIN = 3
TRIGGER_TYPE_CHAIN = 3,
TRIGGER_TYPE_DM = 4
} trigger_type_t;
typedef struct {
@@ -36,6 +38,14 @@ typedef struct {
time_t last_seen_created_at;
time_t last_cron_fire;
char cron_expr[64];
char llm_spec[256];
char tools_policy[256];
int has_max_tokens;
int max_tokens;
int has_temperature;
double temperature;
int has_seed;
int seed;
nostr_pool_subscription_t* subscription;
void* subscription_ctx;
} active_trigger_t;
@@ -61,7 +71,15 @@ int trigger_manager_add(trigger_manager_t* mgr,
const char* filter_json,
trigger_action_type_t action_type,
const char* trigger_type_str,
int enabled);
int enabled,
const char* llm_spec,
const char* tools_policy,
int has_max_tokens,
int max_tokens,
int has_temperature,
double temperature,
int has_seed,
int seed);
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_d_tag);
int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out);
int trigger_manager_update(trigger_manager_t* mgr,
@@ -70,7 +88,15 @@ int trigger_manager_update(trigger_manager_t* mgr,
const char* filter_json,
trigger_action_type_t action_type,
const char* trigger_type_str,
int enabled);
int enabled,
const char* llm_spec,
const char* tools_policy,
int has_max_tokens,
int max_tokens,
int has_temperature,
double temperature,
int has_seed,
int seed);
int trigger_manager_fire(trigger_manager_t* mgr,
const char* skill_d_tag,
cJSON* event,
@@ -79,6 +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_active_count(trigger_manager_t* mgr);
int trigger_manager_poll(trigger_manager_t* mgr);
char* trigger_manager_status_json(trigger_manager_t* mgr);

View File

@@ -63,8 +63,8 @@ assert_grep "trigger_type_to_string" src/trigger_manager.c "Trigger type seriali
assert_grep "\"/api/trigger/\*\"" src/http_api.c "Webhook API route registered"
assert_grep "handle_trigger_webhook\(" src/http_api.c "Webhook handler implemented"
assert_grep "nostr-subscription, webhook, cron, chain" src/tools.c "skill_create validation accepts 4 trigger types"
assert_grep "skill_edit trigger must be one of: nostr-subscription, webhook, cron, chain" src/tools.c "skill_edit validation accepts 4 trigger types"
assert_grep "skill_create trigger must be one of: nostr-subscription, webhook, cron, chain, dm" src/tools/tool_skill.c "skill_create validation accepts supported trigger types"
assert_grep "skill_edit trigger must be one of: nostr-subscription, webhook, cron, chain, dm" src/tools/tool_skill.c "skill_edit validation accepts supported trigger types"
assert_grep "trigger_manager_fire_chains\(" src/agent.c "Post-execution chain hook wired in agent"