Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0bd4c6473 | ||
|
|
cb45012495 | ||
|
|
6c78f6e79f | ||
|
|
421e1abb15 | ||
|
|
c93972019c | ||
|
|
f7dd1f98c6 | ||
|
|
93bfd0741d | ||
|
|
ffed72c9fd | ||
|
|
1b9ecb782c | ||
|
|
aec697ad7b | ||
|
|
6359681cb3 | ||
|
|
1f7bbc9597 | ||
|
|
90adb88a4a | ||
|
|
505d9d7d85 | ||
|
|
e1f5457ced | ||
|
|
5dfb18a842 | ||
|
|
40fe410eb1 | ||
|
|
927c313a87 | ||
|
|
97dca12bb1 | ||
|
|
120664571f | ||
|
|
1bbae06722 | ||
|
|
8b1e9fb734 | ||
|
|
31439652aa | ||
|
|
ad99f74bc0 | ||
|
|
cf9c300fdf | ||
|
|
97585890b9 | ||
|
|
7b3d36a797 | ||
|
|
49579b17a4 | ||
|
|
17083de47d | ||
|
|
33884046af | ||
|
|
7e69819be5 | ||
|
|
d189d0ba8c | ||
|
|
2cda3b6a58 | ||
|
|
ecb58b7e11 | ||
|
|
c3645e6af5 | ||
|
|
6cc46d2c25 | ||
|
|
10fe8fdde0 |
@@ -5,6 +5,7 @@
|
||||
/c-relay/
|
||||
/nips/
|
||||
/config.json
|
||||
/config.jsonc
|
||||
test_keys.txt
|
||||
|
||||
/mongoose/
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
description: "Increments and pushes the repo"
|
||||
---
|
||||
|
||||
Run increment_and_push.sh followed in the command line with a good description of the changes that were made.
|
||||
Run increment_and_push.sh followed in the command line with a good description of the changes that were made. Don't run any other git commands.
|
||||
|
||||
For example: ./increment_and_push.sh "Fixed that nasty bug"
|
||||
|
||||
@@ -90,7 +90,7 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \
|
||||
CURL_LIBS="$(pkg-config --static --libs libcurl)" && \
|
||||
OPENSSL_LIBS="$(pkg-config --static --libs openssl)" && \
|
||||
gcc -static $CFLAGS -Wall -Wextra -std=c99 \
|
||||
-D_GNU_SOURCE -D_DEFAULT_SOURCE -D_POSIX_C_SOURCE=200809L \
|
||||
-D_GNU_SOURCE -D_DEFAULT_SOURCE -D_POSIX_C_SOURCE=200809L -DMG_TLS=MG_TLS_BUILTIN \
|
||||
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
|
||||
-I. -Isrc -Inostr_core_lib -Inostr_core_lib/nostr_core \
|
||||
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
CC = gcc
|
||||
CFLAGS = -std=c99 -Wall -Wextra -Wpedantic -O2 -D_GNU_SOURCE -D_DEFAULT_SOURCE -D_POSIX_C_SOURCE=200809L
|
||||
CFLAGS = -std=c99 -Wall -Wextra -Wpedantic -O2 -D_GNU_SOURCE -D_DEFAULT_SOURCE -D_POSIX_C_SOURCE=200809L -DMG_TLS=MG_TLS_BUILTIN
|
||||
|
||||
SRC_DIR = src
|
||||
TARGET = didactyl
|
||||
|
||||
@@ -45,17 +45,21 @@ Because all identity, communication, and memory live on Nostr, the agent is **po
|
||||
|
||||
### Skills are the new apps.
|
||||
|
||||
Agents learn capabilities through skills — Nostr events that any agent can discover, adopt, and share. There is no app store, no gatekeeper, no approval process. An agent can use public or private skills.
|
||||
Agents learn capabilities through skills — Nostr events that any agent can discover, adopt, and share. There is no app store, no gatekeeper, no approval process. An agent can use public or private skills.
|
||||
|
||||
Think of it like a woodshop: a **skill** is knowing how to carve — the technique, the judgment, the decision-making. A **tool** is the chisel. The skill never directly uses the chisel without the craftsperson (the LLM) in the loop. Every skill execution involves the LLM reasoning about what to do and which tools to use.
|
||||
|
||||
Skills support context modes (`inject`, `full`, `override`) and per-skill LLM fallback chains (for example: `anthropic/claude-sonnet-4-20250514, openai/gpt-4o-mini, cheap`) so each skill can tune behavior and cost. See [`docs/SKILLS.md`](docs/SKILLS.md).
|
||||
|
||||
### Private inference.
|
||||
|
||||
Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers.
|
||||
|
||||
## Current Status — v0.0.29
|
||||
## Current Status — v0.0.63
|
||||
|
||||
**Active build — this project is barely working. Experiment at your own risk.**
|
||||
|
||||
> Last release update: v0.0.29 — Update README: current status, runtime context model, project structure, HTTP admin API section, model tools, roadmap checkboxes
|
||||
> Last release update: v0.0.63 — Deprecate template trigger action and force llm execution path
|
||||
|
||||
- Connects to configured relays with auto-reconnect and relay state transition logging
|
||||
- Publishes configured startup events per relay as each relay becomes connected
|
||||
@@ -66,7 +70,7 @@ Didactyl will support local inference, which is very privacy preserving. Remote
|
||||
- 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
|
||||
- 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
|
||||
- Triggered skills — Nostr event filters that fire skill execution automatically with `template` (deterministic) or `llm` (context-aware) actions; see [`docs/SKILLS.md`](docs/SKILLS.md)
|
||||
- Deduplicates inbound messages via event-ID cache and FNV-1a fingerprint debounce window
|
||||
- Appends every outbound LLM context payload to [`context.log`](context.log)
|
||||
- Localhost HTTP admin API on port `8484` — inspect context, run prompts, compare variants, change model at runtime
|
||||
@@ -80,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.json
|
||||
./didactyl_static_x86_64 --config ./config.jsonc
|
||||
```
|
||||
|
||||
### Build from source (optional)
|
||||
@@ -99,7 +103,7 @@ chmod +x ./didactyl_static_x86_64
|
||||
|
||||
### Configure
|
||||
|
||||
Edit [`config.json`](config.json):
|
||||
Edit [`config.jsonc`](config.jsonc):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -176,13 +180,13 @@ Relays are sourced exclusively from startup kind `10002` `r` tags.
|
||||
### Run
|
||||
|
||||
```bash
|
||||
./didactyl_static_x86_64 --config ./config.json
|
||||
./didactyl_static_x86_64 --config ./config.jsonc
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
```
|
||||
./didactyl_static_x86_64 --config <path> # custom config file (default: ./config.json)
|
||||
./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
|
||||
@@ -195,13 +199,38 @@ CLI debugger notes:
|
||||
- Example:
|
||||
|
||||
```bash
|
||||
./didactyl_static_x86_64 --config ./config.json --test-tool nostr_file_md_to_longform_post '{"file":"docs/TOOLS_AND_SKILLS.md","title":"TOOLS_AND_SKILLS"}'
|
||||
./didactyl_static_x86_64 --config ./config.jsonc --test-tool nostr_file_md_to_longform_post '{"file":"docs/SKILLS.md","title":"SKILLS"}'
|
||||
```
|
||||
|
||||
### Talk to it
|
||||
|
||||
Send an encrypted DM to the agent pubkey using any Nostr client (Damus, Amethyst, Primal, etc.): ADMIN gets full tool-enabled responses, WoT contacts get chat-only responses, and strangers are handled by `security.tiers.stranger` + `security.stranger_response`.
|
||||
|
||||
### Chat via local HTTP API (CLI)
|
||||
|
||||
A simple Node.js terminal client is available in [`didactyl-chat-cli.js`](didactyl-chat-cli.js).
|
||||
|
||||
Run it with:
|
||||
|
||||
```bash
|
||||
node ./didactyl-chat-cli.js
|
||||
```
|
||||
|
||||
Optional environment variables:
|
||||
|
||||
- `DIDACTYL_API_BASE_URL` (default: `https://127.0.0.1:8484`)
|
||||
- `DIDACTYL_MODEL` (optional model override)
|
||||
- `DIDACTYL_MAX_TURNS` (default: `4`)
|
||||
- `DIDACTYL_INSECURE_TLS` (default: `1`, set `0` to enforce certificate verification)
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
DIDACTYL_API_BASE_URL=http://127.0.0.1:8484 DIDACTYL_MAX_TURNS=6 node ./didactyl-chat-cli.js
|
||||
```
|
||||
|
||||
The CLI prints each message block with a speaker label (`You` / `Didactyl`) and a blank line between blocks for readability.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
@@ -231,16 +260,24 @@ Send an encrypted DM to the agent pubkey using any Nostr client (Damus, Amethyst
|
||||
|
||||
## Didactyl Kinds (Nostr)
|
||||
|
||||
Didactyl uses a two-layer skill model: authors publish public skill definitions, and adopters publish which skills they use.
|
||||
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** (markdown skill body in `content` or structured JSON in `content_fields`)
|
||||
- `31123` — **Public Skill Definition** (replaceable by `d` tag)
|
||||
- `content` is JSON with fields like `description`, `context_mode`, `llm`, `tools`, `template`, optional `max_tokens` / `temperature`
|
||||
- `d=<skill_slug>` (example: `d=long_form_note`)
|
||||
- `31124` — **Private Skill Definition** (private/internal procedures)
|
||||
- `31124` — **Private Skill Definition** (same schema as `31123`, private scope)
|
||||
- `d=<skill_slug>` (example: `d=admin_ops`)
|
||||
- `10123` — **Public Skill Adoption List**
|
||||
- tags contain one or more `a` references to selected `31123` skills
|
||||
- `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
|
||||
|
||||
Full skill schema, trigger tags, template variables, fallback resolution, and limits are documented in [`docs/SKILLS.md`](docs/SKILLS.md).
|
||||
|
||||
## Skill Sharing & Discovery
|
||||
|
||||
@@ -262,7 +299,7 @@ Skills are shared across Nostr without any centralized registry or approval proc
|
||||
|
||||
## Startup
|
||||
|
||||
Didactyl startup behavior is configured in [`config.json`](config.json) under `startup_events`.
|
||||
Didactyl startup behavior is configured in [`config.jsonc`](config.jsonc) under `startup_events`.
|
||||
|
||||
Also used at startup:
|
||||
|
||||
@@ -279,15 +316,17 @@ Didactyl builds tier-aware 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 — e.g. `admin_identity`, `admin_profile`, `admin_relay_list`, `startup_events`, `adopted_skills`, `dm_history` (expand), `admin_notes`
|
||||
3. Each section resolves `{{variable}}` placeholders from live data at call time
|
||||
4. Provider-specific content overrides per section (e.g. XML tags for Anthropic)
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
## Tooling Interface
|
||||
|
||||
Current tool schema exposed to the LLM in [`tools_build_openai_schema_json()`](src/tools.c:881):
|
||||
@@ -319,12 +358,12 @@ Current tool schema exposed to the LLM in [`tools_build_openai_schema_json()`](s
|
||||
- `skill_remove`
|
||||
- `skill_search`
|
||||
- Local/host tools:
|
||||
- `shell_exec`
|
||||
- `file_read`
|
||||
- `file_write`
|
||||
- `http_fetch`
|
||||
- `local_shell_exec`
|
||||
- `local_file_read`
|
||||
- `local_file_write`
|
||||
- `local_http_fetch`
|
||||
- Agent metadata:
|
||||
- `my_version`
|
||||
- `agent_version`
|
||||
- Model management:
|
||||
- `model_get`
|
||||
- `model_set`
|
||||
@@ -345,7 +384,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.json) |
|
||||
| `PUT /api/model` | Change model at runtime (persists to config.jsonc) |
|
||||
| `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).
|
||||
@@ -354,7 +393,7 @@ Full reference: [`docs/API.md`](docs/API.md). Frontend brief: [`plans/admin_web_
|
||||
|
||||
```
|
||||
.
|
||||
├── config.json # Agent/runtime config including startup_events + tools
|
||||
├── config.jsonc # Agent/runtime config (JSONC with comments) including startup_events + tools
|
||||
├── context.log # Appended outbound LLM context payloads
|
||||
├── Makefile # Build system
|
||||
├── build_static.sh # Preferred final build validation
|
||||
@@ -373,7 +412,9 @@ Full reference: [`docs/API.md`](docs/API.md). Frontend brief: [`plans/admin_web_
|
||||
│ └── debug.c / .h # Runtime log levels/macros
|
||||
├── docs/
|
||||
│ ├── API.md # HTTP admin API endpoint reference
|
||||
│ └── TOOLS_AND_SKILLS.md # Tool and skill system documentation
|
||||
│ ├── TOOLS.md # Tool architecture and catalog
|
||||
│ ├── SKILLS.md # Skill schema, context modes, triggers, and limits
|
||||
│ └── CRASH_FIXES.md # Crash analysis and fixes log
|
||||
├── plans/ # Architecture and planning documents
|
||||
└── README.md
|
||||
```
|
||||
@@ -390,13 +431,45 @@ All dependencies are statically linked into the binary at build time. No system
|
||||
| libssl / libcrypto | TLS for WebSocket relay connections | Statically linked (Alpine/MUSL) |
|
||||
| libsecp256k1 | Schnorr signatures, ECDH | Statically linked (Alpine/MUSL) |
|
||||
|
||||
## Roadmap: Nostr-Native Portability
|
||||
|
||||
Didactyl's long-term architecture goal is **zero filesystem dependency after first boot**. The config file is the only tie to the local filesystem. The plan:
|
||||
|
||||
1. **First boot** — Read `config.jsonc`, publish all identity, soul, skills, and adoption list as Nostr events to relays.
|
||||
2. **Subsequent boots** — Given only the agent's keys, retrieve everything needed from Nostr relays: soul, skills, adoption list, trigger definitions, admin pubkey, relay list. No config file required.
|
||||
3. **True portability** — Start your agent from any computer. All you need are its keys. All state lives on Nostr.
|
||||
|
||||
This makes Didactyl fundamentally different from filesystem-bound agents. Destroying the host computer does not kill the agent — its identity, memory, and capabilities persist on the relay network.
|
||||
|
||||
### What already lives on Nostr
|
||||
|
||||
| Data | Event Kind | Status |
|
||||
|---|---|---|
|
||||
| Agent profile | Kind 0 | Implemented |
|
||||
| Relay list | Kind 10002 | Implemented |
|
||||
| DM relay list | Kind 10050 | Implemented |
|
||||
| Public skills | Kind 31123 | Implemented |
|
||||
| Private skills | Kind 31124 | Implemented |
|
||||
| Skill adoption list | Kind 10123 | Implemented |
|
||||
| Soul/personality | Kind 31120 | Implemented |
|
||||
| Trigger definitions | Tags on skill events | Implemented |
|
||||
|
||||
### What still needs migration
|
||||
|
||||
| Data | Current Location | Target |
|
||||
|---|---|---|
|
||||
| Admin pubkey | `config.jsonc` | Derive from kind 3 contact list or dedicated config event |
|
||||
| LLM provider/key | `config.jsonc` | Encrypted kind 30078 app-specific event or NIP-78 |
|
||||
| Security tiers | `config.jsonc` | Agent config event on Nostr |
|
||||
| API settings | `config.jsonc` | Local-only — stays on filesystem as runtime flag |
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] MVP chat agent — DM in, LLM response out
|
||||
- [x] Relay pool with auto-reconnect and status logging
|
||||
- [x] Per-relay startup publish on relay-connected transitions
|
||||
- [x] Runtime diagnostics — relay health, message flow, event kind publish logs
|
||||
- [x] Tool-calling loop (nostr_post, nostr_query, shell_exec, file_read, file_write)
|
||||
- [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)
|
||||
@@ -408,7 +481,7 @@ All dependencies are statically linked into the binary at build time. No system
|
||||
- [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.json)
|
||||
- [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
|
||||
- [ ] Runtime skill loading from adopted `31123` events on relays
|
||||
- [ ] Skill discovery CLI/tool (query WoT adoption lists)
|
||||
|
||||
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Simple terminal chat client for Didactyl HTTP API.
|
||||
*
|
||||
* Usage:
|
||||
* node didactyl-chat-cli.js
|
||||
*
|
||||
* Optional env vars:
|
||||
* DIDACTYL_API_BASE_URL=http://127.0.0.1:8484
|
||||
* DIDACTYL_MODEL=claude-haiku-4.5
|
||||
* DIDACTYL_MAX_TURNS=4
|
||||
*/
|
||||
|
||||
const readline = require("node:readline/promises");
|
||||
const { stdin, stdout } = require("node:process");
|
||||
const http = require("node:http");
|
||||
const https = require("node:https");
|
||||
|
||||
const API_BASE_URL = process.env.DIDACTYL_API_BASE_URL || "https://127.0.0.1:8484";
|
||||
const MODEL = process.env.DIDACTYL_MODEL || "";
|
||||
const MAX_TURNS = Number.parseInt(process.env.DIDACTYL_MAX_TURNS || "4", 10);
|
||||
const INSECURE_TLS = !["0", "false", "False", "FALSE"].includes(
|
||||
String(process.env.DIDACTYL_INSECURE_TLS || "1")
|
||||
);
|
||||
|
||||
function printMessage(role, content) {
|
||||
const who = role === "user" ? "You" : role === "assistant" ? "Didactyl" : role;
|
||||
console.log(`${who}>`);
|
||||
console.log(content);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
function postJson(urlString, payload) {
|
||||
const url = new URL(urlString);
|
||||
const isHttps = url.protocol === "https:";
|
||||
const data = JSON.stringify(payload);
|
||||
|
||||
const options = {
|
||||
method: "POST",
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname + url.search,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(data),
|
||||
},
|
||||
};
|
||||
|
||||
if (isHttps) {
|
||||
options.rejectUnauthorized = !INSECURE_TLS;
|
||||
}
|
||||
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = client.request(options, (res) => {
|
||||
let body = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
statusCode: res.statusCode || 0,
|
||||
body,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function callDidactyl(message) {
|
||||
const body = {
|
||||
message,
|
||||
max_turns: Number.isFinite(MAX_TURNS) ? MAX_TURNS : 4,
|
||||
};
|
||||
|
||||
if (MODEL.trim()) {
|
||||
body.model = MODEL.trim();
|
||||
}
|
||||
|
||||
const { statusCode, body: responseBody } = await postJson(`${API_BASE_URL}/api/prompt/agent`, body);
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
throw new Error(`HTTP ${statusCode}: ${responseBody}`);
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(responseBody);
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON from API: ${responseBody}`);
|
||||
}
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || "Didactyl API returned success=false");
|
||||
}
|
||||
|
||||
return String(data.final_response || "");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Didactyl CLI chat");
|
||||
console.log(`API: ${API_BASE_URL}`);
|
||||
console.log(`TLS verify: ${INSECURE_TLS ? "disabled (local dev)" : "enabled"}`);
|
||||
console.log("Type /exit to quit.\n");
|
||||
|
||||
const rl = readline.createInterface({ input: stdin, output: stdout });
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const input = (await rl.question("You> ")).trim();
|
||||
|
||||
if (!input) {
|
||||
console.log("");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input === "/exit" || input === "/quit") {
|
||||
console.log("Exiting.");
|
||||
break;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
|
||||
try {
|
||||
const reply = await callDidactyl(input);
|
||||
printMessage("assistant", reply);
|
||||
console.log("");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Didactyl error: ${message}`);
|
||||
console.error("");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Fatal error: ${message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,259 +0,0 @@
|
||||
{
|
||||
"keys": {
|
||||
"nsec": "agent nsec",
|
||||
"npub": "agent npub",
|
||||
"npubHex": "agent hex pubkey",
|
||||
"nsecHex": "agent hex secret key"
|
||||
},
|
||||
"admin": {
|
||||
"pubkey": "admin pubkey"
|
||||
},
|
||||
"llm": {
|
||||
"provider": "",
|
||||
"api_key": "",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.7
|
||||
},
|
||||
"security": {
|
||||
"verify_signatures": true,
|
||||
"stranger_response": "I only respond to people in my web of trust. You can always identify me by my public key (npub).",
|
||||
"tiers": {
|
||||
"admin": {
|
||||
"tools_enabled": true
|
||||
},
|
||||
"wot": {
|
||||
"enabled": true,
|
||||
"tools_enabled": false
|
||||
},
|
||||
"stranger": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin_context": {
|
||||
"enabled": true,
|
||||
"subscribe_kinds": [
|
||||
0,
|
||||
3,
|
||||
10002,
|
||||
1
|
||||
],
|
||||
"kind_1_limit": 10
|
||||
},
|
||||
"api": {
|
||||
"enabled": true,
|
||||
"port": 8484,
|
||||
"bind_address": "127.0.0.1"
|
||||
},
|
||||
"startup_events": [
|
||||
{
|
||||
"kind": 0,
|
||||
"content_fields": {
|
||||
"name": "Didactyl Agent",
|
||||
"display_name": "Didactyl",
|
||||
"about": "A sovereign AI agent on Nostr",
|
||||
"picture": "https://laantungir.github.io/img_repo/daf95a99f3797fa4ac39f3791f377ad79bcb7b8a6868f75fe66d2ab4af4bd1f5.png",
|
||||
"banner": "https://laantungir.github.io/img_repo/d21c4060632ab3d9d37a6062eeecbdbfbd67f03cb20dbd64838b1ba0d9cd8922.jpg"
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"kind": 10002,
|
||||
"content": "",
|
||||
"tags": [
|
||||
|
||||
|
||||
[
|
||||
"r",
|
||||
"wss://relay.damus.io"
|
||||
],
|
||||
[
|
||||
"r",
|
||||
"wss://nos.lol"
|
||||
],
|
||||
[
|
||||
"r",
|
||||
"wss://relay.primal.net"
|
||||
],
|
||||
[
|
||||
"r",
|
||||
"ws://127.0.0.1:7777"
|
||||
]
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 1,
|
||||
"content": "Hello world from Didactyl startup",
|
||||
"tags": [
|
||||
[
|
||||
"t",
|
||||
"didactyl"
|
||||
],
|
||||
[
|
||||
"t",
|
||||
"startup"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 3,
|
||||
"content": "",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"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 `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## 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_relay_list\n role: system\n content: |\n ## Administrator Relay List (source: nostr kind 10002)\n\n Administrator kind 10002 relay-list content (JSON): {{admin_kind10002_json}}\n\n- section: startup_events\n role: system\n content: |\n ## Startup Events Memory (source: config.startup_events)\n\n Startup events memory (kinds/content/tags): {{startup_events_json}}\n\n- section: adopted_skills\n role: system\n content: |\n {{adopted_skills_content}}\n\n- section: dm_history\n role: expand\n limit: 12\n\n- section: admin_notes\n role: system\n content: |\n ## Administrator Recent Notes (source: nostr kind 1)\n\n {{admin_notes_content}}",
|
||||
"tags": [
|
||||
[
|
||||
"d",
|
||||
"soul"
|
||||
],
|
||||
[
|
||||
"app",
|
||||
"didactyl"
|
||||
],
|
||||
[
|
||||
"scope",
|
||||
"private"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 31123,
|
||||
"content_fields": {
|
||||
"name": "long_form_note",
|
||||
"description": "How to publish a NIP-23 long-form article (kind 30023)",
|
||||
"nip": "NIP-23",
|
||||
"event_kind": 30023,
|
||||
"format": "The content field must be markdown text; avoid arbitrary hard line-breaks in paragraphs and do not include HTML.",
|
||||
"required_tags": {
|
||||
"d": "Addressable identifier slug for the article. Reusing the same d tag replaces prior versions.",
|
||||
"title": "Human-readable article title.",
|
||||
"published_at": "Unix timestamp as a string for first publication time; keep stable on edits."
|
||||
},
|
||||
"optional_tags": {
|
||||
"summary": "Short 1-2 sentence summary.",
|
||||
"image": "URL for article preview image.",
|
||||
"t": "Topic hashtags using repeated t tags, usually 3-6 lowercase terms."
|
||||
},
|
||||
"behavior": "If required values are missing or unclear, ask the administrator before publishing instead of guessing.",
|
||||
"procedure": [
|
||||
"Determine title and d tag from admin input or source material.",
|
||||
"Draft markdown body content.",
|
||||
"Set published_at as unix seconds string.",
|
||||
"Add optional summary/image/t tags when known.",
|
||||
"Publish with nostr_post kind 30023 including tags array."
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
[
|
||||
"d",
|
||||
"long_form_note"
|
||||
],
|
||||
[
|
||||
"app",
|
||||
"didactyl"
|
||||
],
|
||||
[
|
||||
"scope",
|
||||
"public"
|
||||
],
|
||||
[
|
||||
"slug",
|
||||
"long_form_note"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 31123,
|
||||
"content_fields": {
|
||||
"name": "post_readme_to_nostr",
|
||||
"description": "Read README.md from the repo and publish it as a NIP-23 long-form note",
|
||||
"uses_skill": "long_form_note",
|
||||
"event_kind": 30023,
|
||||
"procedure": [
|
||||
"Read README.md using file_read with path README.md.",
|
||||
"Set d tag exactly to readme.md.",
|
||||
"Set title from the first markdown H1 heading in README.md.",
|
||||
"Set summary from the opening paragraph of README.md.",
|
||||
"Set image from project metadata when available (kind 0 picture/banner), otherwise omit image tag.",
|
||||
"Generate 3-6 lowercase t tags from README section topics.",
|
||||
"Set published_at to current unix timestamp as string.",
|
||||
"Publish with nostr_post kind 30023, full README markdown in content, and full NIP-23 tag set."
|
||||
],
|
||||
"required_values": {
|
||||
"d": "readme.md",
|
||||
"summary_source": "opening paragraph"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
[
|
||||
"d",
|
||||
"post_readme_to_nostr"
|
||||
],
|
||||
[
|
||||
"app",
|
||||
"didactyl"
|
||||
],
|
||||
[
|
||||
"scope",
|
||||
"public"
|
||||
],
|
||||
[
|
||||
"slug",
|
||||
"post_readme_to_nostr"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 31124,
|
||||
"content_fields": {
|
||||
"name": "admin_ops",
|
||||
"description": "Private operational procedures"
|
||||
},
|
||||
"tags": [
|
||||
[
|
||||
"d",
|
||||
"admin_ops"
|
||||
],
|
||||
[
|
||||
"app",
|
||||
"didactyl"
|
||||
],
|
||||
[
|
||||
"scope",
|
||||
"private"
|
||||
],
|
||||
[
|
||||
"slug",
|
||||
"admin_ops"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 10123,
|
||||
"content": "",
|
||||
"tags": [
|
||||
[
|
||||
"a",
|
||||
"31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:long_form_note"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:post_readme_to_nostr"
|
||||
],
|
||||
[
|
||||
"app",
|
||||
"didactyl"
|
||||
],
|
||||
[
|
||||
"scope",
|
||||
"public"
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
{
|
||||
// ─── Agent Identity Keys ───────────────────────────────────────────
|
||||
// Your agent's Nostr keypair. Provide nsec (bech32) or hex format.
|
||||
// The public key fields are optional — they are derived automatically.
|
||||
"keys": {
|
||||
"nsec": "agent nsec",
|
||||
"npub": "agent npub",
|
||||
"npubHex": "agent hex pubkey",
|
||||
"nsecHex": "agent hex secret key"
|
||||
},
|
||||
|
||||
// ─── Administrator ─────────────────────────────────────────────────
|
||||
// The admin pubkey (npub or hex) controls who can issue privileged
|
||||
// commands and use tools via DM.
|
||||
"admin": {
|
||||
"pubkey": "admin pubkey"
|
||||
},
|
||||
|
||||
// ─── DM Protocol ──────────────────────────────────────────────────
|
||||
// Which encrypted DM protocol to use: "nip04", "nip17", or "both"
|
||||
"dm_protocol": "nip04",
|
||||
|
||||
// ─── LLM Provider ─────────────────────────────────────────────────
|
||||
// Configure the language model backend. Any OpenAI-compatible API works.
|
||||
"llm": {
|
||||
"provider": "", // e.g. "openai", "anthropic", etc.
|
||||
"api_key": "", // your API key
|
||||
"model": "", // model identifier, e.g. "gpt-4o-mini"
|
||||
"base_url": "", // API base URL, e.g. "https://api.openai.com/v1"
|
||||
"max_tokens": 512, // max tokens per LLM response
|
||||
"temperature": 0.7 // sampling temperature (0.0 – 2.0)
|
||||
},
|
||||
|
||||
// ─── Security Tiers ───────────────────────────────────────────────
|
||||
// Controls who can interact with the agent and what they can do.
|
||||
"security": {
|
||||
"verify_signatures": true, // verify Nostr event signatures
|
||||
// Message sent to strangers outside the web of trust
|
||||
"stranger_response": "I only respond to people in my web of trust. You can always identify me by my public key (npub).",
|
||||
"tiers": {
|
||||
"admin": {
|
||||
"tools_enabled": true // admin can always use tools
|
||||
},
|
||||
"wot": {
|
||||
"enabled": true, // respond to web-of-trust contacts
|
||||
"tools_enabled": false // WoT contacts cannot use tools by default
|
||||
},
|
||||
"stranger": {
|
||||
"enabled": true // respond to strangers (with stranger_response)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ─── Admin Context Subscriptions ──────────────────────────────────
|
||||
// Subscribe to the admin's Nostr events to build context awareness.
|
||||
"admin_context": {
|
||||
"enabled": true,
|
||||
"subscribe_kinds": [
|
||||
0, // kind 0: profile metadata
|
||||
3, // kind 3: contact list
|
||||
10002, // kind 10002: relay list
|
||||
1 // kind 1: text notes
|
||||
],
|
||||
"kind_1_limit": 10 // max recent kind-1 notes to track
|
||||
},
|
||||
|
||||
// ─── HTTP Admin API ───────────────────────────────────────────────
|
||||
// Local REST API for runtime inspection and control.
|
||||
"api": {
|
||||
"enabled": true,
|
||||
"port": 8484,
|
||||
"bind_address": "127.0.0.1" // bind to localhost only
|
||||
},
|
||||
|
||||
// ─── Startup Events ───────────────────────────────────────────────
|
||||
// Events published on boot. Includes profile, relay list, soul,
|
||||
// skills, and adoption list.
|
||||
"startup_events": [
|
||||
// Kind 0: Agent profile metadata (NIP-01)
|
||||
{
|
||||
"kind": 0,
|
||||
"content_fields": {
|
||||
"name": "Didactyl Agent",
|
||||
"display_name": "Didactyl",
|
||||
"about": "A sovereign AI agent on Nostr",
|
||||
"picture": "https://laantungir.github.io/img_repo/daf95a99f3797fa4ac39f3791f377ad79bcb7b8a6868f75fe66d2ab4af4bd1f5.png",
|
||||
"banner": "https://laantungir.github.io/img_repo/d21c4060632ab3d9d37a6062eeecbdbfbd67f03cb20dbd64838b1ba0d9cd8922.jpg"
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
|
||||
// Kind 10002: Relay list (NIP-65)
|
||||
// These relays are used for connecting and publishing events.
|
||||
{
|
||||
"kind": 10002,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["r", "wss://relay.damus.io"],
|
||||
["r", "wss://nos.lol"],
|
||||
["r", "wss://relay.primal.net"],
|
||||
["r", "ws://127.0.0.1:7777"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 10050: DM relay list (NIP-17)
|
||||
// Relays used specifically for receiving encrypted DMs.
|
||||
{
|
||||
"kind": 10050,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["relay", "wss://relay.damus.io"],
|
||||
["relay", "wss://nos.lol"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 1: Startup announcement note
|
||||
{
|
||||
"kind": 1,
|
||||
"content": "Hello world from Didactyl startup",
|
||||
"tags": [
|
||||
["t", "didactyl"],
|
||||
["t", "startup"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 3: Contact list (initially empty)
|
||||
{
|
||||
"kind": 3,
|
||||
"content": "",
|
||||
"tags": []
|
||||
},
|
||||
|
||||
// Kind 31120: Soul event — the agent's personality and behavior rules.
|
||||
// Contains the system prompt and template sections for LLM context.
|
||||
// The ---template--- marker separates the system prompt from
|
||||
// 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}}",
|
||||
"tags": [
|
||||
["d", "soul"],
|
||||
["app", "didactyl"],
|
||||
["scope", "private"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 31123: Public skill — long_form_note
|
||||
// Teaches the agent how to publish NIP-23 long-form articles.
|
||||
{
|
||||
"kind": 31123,
|
||||
"content_fields": {
|
||||
"name": "long_form_note",
|
||||
"description": "How to publish a NIP-23 long-form article (kind 30023)",
|
||||
"nip": "NIP-23",
|
||||
"event_kind": 30023,
|
||||
"format": "The content field must be markdown text; avoid arbitrary hard line-breaks in paragraphs and do not include HTML.",
|
||||
"required_tags": {
|
||||
"d": "Addressable identifier slug for the article. Reusing the same d tag replaces prior versions.",
|
||||
"title": "Human-readable article title.",
|
||||
"published_at": "Unix timestamp as a string for first publication time; keep stable on edits."
|
||||
},
|
||||
"optional_tags": {
|
||||
"summary": "Short 1-2 sentence summary.",
|
||||
"image": "URL for article preview image.",
|
||||
"t": "Topic hashtags using repeated t tags, usually 3-6 lowercase terms."
|
||||
},
|
||||
"behavior": "If required values are missing or unclear, ask the administrator before publishing instead of guessing.",
|
||||
"procedure": [
|
||||
"Determine title and d tag from admin input or source material.",
|
||||
"Draft markdown body content.",
|
||||
"Set published_at as unix seconds string.",
|
||||
"Add optional summary/image/t tags when known.",
|
||||
"Publish with nostr_post kind 30023 including tags array."
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
["d", "long_form_note"],
|
||||
["app", "didactyl"],
|
||||
["scope", "public"],
|
||||
["slug", "long_form_note"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 31123: Public skill — post_readme_to_nostr
|
||||
// Reads README.md and publishes it as a long-form note.
|
||||
{
|
||||
"kind": 31123,
|
||||
"content_fields": {
|
||||
"name": "post_readme_to_nostr",
|
||||
"description": "Read README.md from the repo and publish it as a NIP-23 long-form note",
|
||||
"uses_skill": "long_form_note",
|
||||
"event_kind": 30023,
|
||||
"procedure": [
|
||||
"Read README.md using local_file_read with path README.md.",
|
||||
"Set d tag exactly to readme.md.",
|
||||
"Set title from the first markdown H1 heading in README.md.",
|
||||
"Set summary from the opening paragraph of README.md.",
|
||||
"Set image from project metadata when available (kind 0 picture/banner), otherwise omit image tag.",
|
||||
"Generate 3-6 lowercase t tags from README section topics.",
|
||||
"Set published_at to current unix timestamp as string.",
|
||||
"Publish with nostr_post kind 30023, full README markdown in content, and full NIP-23 tag set."
|
||||
],
|
||||
"required_values": {
|
||||
"d": "readme.md",
|
||||
"summary_source": "opening paragraph"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
["d", "post_readme_to_nostr"],
|
||||
["app", "didactyl"],
|
||||
["scope", "public"],
|
||||
["slug", "post_readme_to_nostr"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 31123: Public triggered skill — cheerleader
|
||||
// Watches for admin kind-1 notes and sends an encouraging DM.
|
||||
{
|
||||
"kind": 31123,
|
||||
"content": "You are my personal cheerleader. When the admin posts a kind 1 note, read their note and send them a short DM that cheers them on, praises their effort, tells them they are good looking, and encourages them to keep going. Be warm, playful, and positive. Mention something specific from their note so it feels personal. Keep it to 2-3 sentences.",
|
||||
"tags": [
|
||||
["d", "cheerleader"],
|
||||
["app", "didactyl"],
|
||||
["scope", "public"],
|
||||
["description", "Cheer on admin whenever they post a kind 1 note"],
|
||||
["trigger", "nostr-subscription"],
|
||||
["filter", "{\"kinds\":[1],\"authors\":[\"REPLACE_WITH_ADMIN_HEX_PUBKEY\"]}"],
|
||||
["action", "llm"],
|
||||
["enabled", "true"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 31124: Private skill — admin_ops
|
||||
// Private operational procedures (admin-only).
|
||||
{
|
||||
"kind": 31124,
|
||||
"content_fields": {
|
||||
"name": "admin_ops",
|
||||
"description": "Private operational procedures"
|
||||
},
|
||||
"tags": [
|
||||
["d", "admin_ops"],
|
||||
["app", "didactyl"],
|
||||
["scope", "private"],
|
||||
["slug", "admin_ops"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 10123: Skill adoption list
|
||||
// References which public skills this agent has adopted.
|
||||
{
|
||||
"kind": 10123,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:long_form_note"],
|
||||
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:post_readme_to_nostr"],
|
||||
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:cheerleader"],
|
||||
["app", "didactyl"],
|
||||
["scope", "public"]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+10741
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
# Context Template
|
||||
|
||||
```yaml
|
||||
- section: admin_identity
|
||||
role: system
|
||||
tool: admin_identity
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_profile
|
||||
role: system
|
||||
tool: nostr_admin_profile
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_contacts
|
||||
role: system
|
||||
tool: nostr_admin_contacts
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_relays
|
||||
role: system
|
||||
tool: nostr_admin_relays
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_notes
|
||||
role: system
|
||||
tool: nostr_admin_notes
|
||||
skip_if_empty: true
|
||||
|
||||
- section: tasks
|
||||
role: system
|
||||
tool: task_list
|
||||
skip_if_empty: true
|
||||
|
||||
- section: conversation
|
||||
role: user
|
||||
tool: message_current
|
||||
skip_if_empty: true
|
||||
|
||||
- section: dm_history
|
||||
role: expand
|
||||
limit: 12
|
||||
```
|
||||
+1272
File diff suppressed because it is too large
Load Diff
+98
-4
@@ -10,7 +10,7 @@ All responses are JSON. CORS headers are included on every response for browser
|
||||
|
||||
## Configuration
|
||||
|
||||
Enable the API in `config.json`:
|
||||
Enable the API in `config.jsonc`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -190,6 +190,45 @@ Returns the context broken into labeled, individually-sized parts. Useful for un
|
||||
|
||||
---
|
||||
|
||||
### POST /api/trigger/:d_tag
|
||||
|
||||
Fires a webhook-triggered skill by `d_tag`.
|
||||
|
||||
**Path params:**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `d_tag` | yes | Skill `d` tag of a skill configured with `trigger=webhook` |
|
||||
|
||||
**Request body:**
|
||||
|
||||
Optional JSON payload; if present it is passed into the synthetic trigger event as `payload`.
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "external-cron",
|
||||
"note": "run maintenance sweep"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"d_tag": "maintenance-sweep",
|
||||
"fired": true
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
|
||||
- Returns `404` if no skill exists for `d_tag` or no active trigger is registered.
|
||||
- Returns `400` if the matched skill is not a `webhook` trigger or is disabled.
|
||||
- Trigger execution uses the same trigger cooldown policy as other trigger types.
|
||||
|
||||
---
|
||||
|
||||
### POST /api/prompt/run-simple
|
||||
|
||||
Submit a system prompt and user message for a simple LLM call with no tools. Useful for quick prompt iteration.
|
||||
@@ -224,9 +263,64 @@ Submit a system prompt and user message for a simple LLM call with no tools. Use
|
||||
|
||||
---
|
||||
|
||||
### POST /api/prompt/agent
|
||||
|
||||
Submit one user message and let Didactyl build full admin context server-side (same context assembly path used for Nostr admin DMs, including admin profile, adopted skills, and recent DM history).
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Tweet about the weather",
|
||||
"model": "claude-haiku-4.5",
|
||||
"max_turns": 5
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `message` | yes | User message string |
|
||||
| `model` | no | Override the current model for this request only |
|
||||
| `max_turns` | no | Maximum tool-call loop iterations (default: 4, max: 16) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"final_response": "Done! I posted a tweet about the weather.",
|
||||
"turns": [
|
||||
{
|
||||
"turn": 1,
|
||||
"tool_calls": [
|
||||
{
|
||||
"name": "nostr_post",
|
||||
"arguments": "{\"kind\":1,\"content\":\"Beautiful day!\"}",
|
||||
"result": "{\"success\":true,\"event_id\":\"abc123\"}"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"model_used": "claude-haiku-4.5",
|
||||
"total_input_tokens_estimate": 3200,
|
||||
"total_output_tokens_estimate": 180
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `final_response` | The LLM final text response after all tool calls complete |
|
||||
| `turns` | Array of turn objects, each containing tool calls made in that turn |
|
||||
| `turns[].tool_calls[]` | Each tool call with name, arguments JSON, and result JSON |
|
||||
| `model_used` | The model that was actually used |
|
||||
| `total_input_tokens_estimate` | Estimated input tokens for the full conversation |
|
||||
| `total_output_tokens_estimate` | Estimated output tokens for the final response |
|
||||
|
||||
---
|
||||
|
||||
### POST /api/prompt/run
|
||||
|
||||
Submit a full messages array with the agent tool set enabled. The agent executes tool calls in a loop and returns the complete conversation trace.
|
||||
Submit a full messages array with the agent tool set enabled. This endpoint runs exactly what you provide and does **not** auto-build Didactyl admin context.
|
||||
|
||||
**Request:**
|
||||
|
||||
@@ -422,8 +516,8 @@ The following endpoints are planned but not yet implemented:
|
||||
| GET | `/api/events/soul` | Fetch agent soul event |
|
||||
| PUT | `/api/events/soul` | Update soul content |
|
||||
| GET | `/api/events/skills` | List published skills |
|
||||
| GET | `/api/events/skills/:slug` | Fetch skill by slug |
|
||||
| PUT | `/api/events/skills/:slug` | Update skill |
|
||||
| GET | `/api/events/skills/:d_tag` | Fetch skill by d_tag |
|
||||
| PUT | `/api/events/skills/:d_tag` | Update skill |
|
||||
| GET | `/api/events/adoption` | Fetch adoption list |
|
||||
| GET | `/api/events/profile` | Fetch agent profile |
|
||||
| PUT | `/api/events/profile` | Update agent profile |
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
# Didactyl — LLM Context
|
||||
|
||||
See also: [SKILLS.md](SKILLS.md) · [TOOLS.md](TOOLS.md)
|
||||
|
||||
## What Is Context?
|
||||
|
||||
Every time Didactyl talks to an LLM, it sends a **context** — the complete package of information the model needs to reason and respond. Understanding context is fundamental to understanding how Didactyl works.
|
||||
|
||||
Context is not just "the prompt." It is everything the LLM receives in a single request:
|
||||
|
||||
1. **Messages** — the conversation: system prompts, user messages, assistant responses, tool results
|
||||
2. **Tool schemas** — the JSON descriptions of every tool the LLM can call
|
||||
3. **Model parameters** — temperature, max tokens, etc.
|
||||
|
||||
The LLM sees all of this together and produces its response based on the totality of what it was given.
|
||||
|
||||
---
|
||||
|
||||
## The OpenAI Chat Completion Format
|
||||
|
||||
Didactyl uses the OpenAI-compatible chat completion API format, which is the industry standard. Every LLM request looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "claude-opus-4.6",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are Didactyl, a sovereign AI agent..."},
|
||||
{"role": "system", "content": "Admin profile: ..."},
|
||||
{"role": "user", "content": "Post a note about Bitcoin"},
|
||||
{"role": "assistant", "content": null, "tool_calls": [...]},
|
||||
{"role": "tool", "content": "{\"success\":true,...}", "tool_call_id": "..."},
|
||||
{"role": "assistant", "content": "Done! I posted your note."}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "nostr_post",
|
||||
"description": "Publish a Nostr event",
|
||||
"parameters": { ... }
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 512
|
||||
}
|
||||
```
|
||||
|
||||
### Messages
|
||||
|
||||
Messages are an ordered array. Each message has a `role`:
|
||||
|
||||
| Role | Purpose |
|
||||
|------|---------|
|
||||
| `system` | Instructions, identity, context data — the LLM reads these but the user didn't write them |
|
||||
| `user` | What the human (or trigger event) said |
|
||||
| `assistant` | What the LLM previously said or did (including tool calls) |
|
||||
| `tool` | Results from tool executions, fed back to the LLM |
|
||||
|
||||
The LLM processes all messages in order and generates the next response.
|
||||
|
||||
### Tool Schemas
|
||||
|
||||
The `tools` array describes every tool the LLM can call. Each tool has a name, description, and JSON Schema for its parameters. The LLM uses these descriptions to decide when and how to call tools.
|
||||
|
||||
Tools are not part of the message history — they are a separate capability declaration sent alongside the messages.
|
||||
|
||||
### Model Parameters
|
||||
|
||||
Temperature, max tokens, and other settings control how the LLM generates its response. These can be set globally or overridden per-skill.
|
||||
|
||||
---
|
||||
|
||||
## How Didactyl Builds Context
|
||||
|
||||
Didactyl assembles context dynamically for each request. The context varies depending on what triggered the request (DM, trigger event) and which skill is active.
|
||||
|
||||
### Context Assembly Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
INPUT[Input: DM message or trigger event] --> MODE{Skill context_mode?}
|
||||
|
||||
MODE -->|inject| INJECT[Start with agent soul/personality]
|
||||
INJECT --> ADD_ADMIN[Add admin identity + profile]
|
||||
ADD_ADMIN --> ADD_SKILLS[Add adopted skill instructions]
|
||||
ADD_SKILLS --> ADD_HISTORY[Add conversation history]
|
||||
ADD_HISTORY --> ADD_SKILL_INST[Append this skill instructions]
|
||||
ADD_SKILL_INST --> ADD_USER[Add user message or trigger event]
|
||||
|
||||
MODE -->|full| FULL[Start with skill template only]
|
||||
FULL --> RESOLVE[Resolve template variables via tool calls]
|
||||
RESOLVE --> ADD_USER2[Add user message or trigger event]
|
||||
|
||||
MODE -->|override| OVERRIDE[Replace soul with skill prompt]
|
||||
OVERRIDE --> ADD_ADMIN2[Add admin identity + profile]
|
||||
ADD_ADMIN2 --> ADD_SKILLS2[Add adopted skill instructions]
|
||||
ADD_SKILLS2 --> ADD_HISTORY2[Add conversation history]
|
||||
ADD_HISTORY2 --> ADD_USER3[Add user message or trigger event]
|
||||
|
||||
ADD_USER --> TOOLS[Attach tool schemas]
|
||||
ADD_USER2 --> TOOLS
|
||||
ADD_USER3 --> TOOLS
|
||||
|
||||
TOOLS --> LLM[Send to LLM]
|
||||
```
|
||||
|
||||
### Context Parts
|
||||
|
||||
These are the building blocks that get assembled into the messages array:
|
||||
|
||||
| Part | Source | Description |
|
||||
|------|--------|-------------|
|
||||
| **Soul/Personality** | Kind 31120 event | The agent's identity, rules, and behavior instructions |
|
||||
| **Admin Identity** | Config / kind 3 | Who the admin is, their pubkey |
|
||||
| **Admin Profile** | Kind 0 event | Admin's display name, about, picture |
|
||||
| **Admin Relay List** | Kind 10002 event | Admin's preferred relays |
|
||||
| **Admin Notes** | Kind 1 events | Admin's recent public posts — gives the agent awareness of what the admin is doing |
|
||||
| **Adopted Skills** | Kind 31123/31124 events | Instructions from other adopted skills |
|
||||
| **Conversation History** | Kind 4/14 events | Recent DM exchanges between admin and agent |
|
||||
| **Skill Instructions** | Active skill content | The specific skill being executed |
|
||||
| **User Message** | DM or trigger event | The input that triggered this execution |
|
||||
| **Tool Schemas** | Tool registry | JSON descriptions of available tools (sent separately from messages) |
|
||||
|
||||
### Template Variables Are Tool Calls
|
||||
|
||||
The soul's template system uses `tool:` directives to populate context sections. Each section can specify a tool to call, and the tool's output becomes that section's content:
|
||||
|
||||
```yaml
|
||||
- section: admin_profile
|
||||
role: system
|
||||
tool: nostr_admin_profile
|
||||
skip_if_empty: true
|
||||
```
|
||||
|
||||
This calls `nostr_admin_profile`, gets the result, and inserts it as a system message. Template variables like `{{admin_profile}}` in skill templates work the same way — they resolve to tool calls. There is one unified resolution mechanism: `tools_execute()`.
|
||||
|
||||
This means new context sources are added by adding tools, not by modifying the template engine.
|
||||
|
||||
---
|
||||
|
||||
## Context Modes
|
||||
|
||||
Skills control how much of the default context the LLM sees using the `context_mode` field:
|
||||
|
||||
| Mode | What the LLM sees | Use case |
|
||||
|------|-------------------|----------|
|
||||
| `inject` | Everything: soul + admin context + history + skill instructions | Default. Agent stays itself, gains new knowledge. |
|
||||
| `full` | Only what the skill template provides | Focused tasks: spellcheck, translation. Minimal context = cheaper, faster. |
|
||||
| `override` | Skill replaces soul, but admin context + history still included | Different personality, same capabilities. |
|
||||
|
||||
Most skills use `inject` (the default). `full` is for specialized tasks where you want to minimize token cost. `override` is rare.
|
||||
|
||||
---
|
||||
|
||||
## Context for Triggered Skills
|
||||
|
||||
When a trigger fires (Nostr subscription match, webhook POST, cron timer, chain completion), the context is assembled the same way as for a DM — the only difference is the user message:
|
||||
|
||||
| Input Source | User Message Content |
|
||||
|---|---|
|
||||
| Admin DM | The DM text |
|
||||
| Nostr subscription trigger | The matching Nostr event as JSON |
|
||||
| Webhook trigger | The HTTP POST payload as JSON |
|
||||
| Cron trigger | A synthetic event with timestamp and cron expression |
|
||||
| Chain trigger | A synthetic event with the source skill's d_tag and output |
|
||||
|
||||
The LLM receives the skill instructions as system context and the triggering event as the user message, then reasons about what to do and which tools to use.
|
||||
|
||||
---
|
||||
|
||||
## Token Budget
|
||||
|
||||
Context has a cost — every token sent to the LLM costs money and takes time. Didactyl manages this through:
|
||||
|
||||
- **Context mode selection** — `full` mode skills send minimal context
|
||||
- **Skip-if-empty sections** — template sections with no content are omitted
|
||||
- **History limits** — conversation history is capped (default: 12 turns)
|
||||
- **Skill content limits** — adopted skill instructions are truncated to prevent context overflow
|
||||
- **Per-skill model selection** — cheap models for simple tasks, expensive models for complex ones
|
||||
|
||||
The `/api/context/current` and `/api/context/parts` API endpoints let you inspect exactly what context would be sent right now, including character counts and token estimates.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- Skills — how to define LLM execution units: [SKILLS.md](SKILLS.md)
|
||||
- Tools — what the LLM can do: [TOOLS.md](TOOLS.md)
|
||||
- API — inspect context at runtime: [API.md](API.md)
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
# Didactyl — Skills
|
||||
|
||||
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.
|
||||
|
||||
Think of it like a woodshop: a **skill** is knowing how to carve — the technique, the judgment, the decision-making. A **tool** is the chisel. The skill never directly uses the chisel without the craftsperson (the LLM) in the loop. If you want a hardcoded program that runs without reasoning, that's a tool or an external program — not a skill.
|
||||
|
||||
Skills are portable, shareable, and discoverable — they live on Nostr relays as standard events. Every skill execution involves the LLM.
|
||||
|
||||
---
|
||||
|
||||
## Skill Events
|
||||
|
||||
| Kind | Purpose | Replaceable? |
|
||||
|---|---|---|
|
||||
| `31123` | Public skill definition | Yes, by d-tag |
|
||||
| `31124` | Private skill definition | Yes, by d-tag |
|
||||
| `10123` | Skill adoption list | Yes, single per pubkey |
|
||||
|
||||
---
|
||||
|
||||
## Skill Content
|
||||
|
||||
The skill event's `content` field is a JSON object that defines the complete execution specification:
|
||||
|
||||
```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,
|
||||
"template": "system:\nYou are a spelling and grammar checker.\n\nRules:\n- Fix spelling errors\n- Fix grammar errors\n- Preserve original formatting\n- Ignore: API, JSON, HTTP, nostr, pubkey, npub, nsec, NIP\n- Canadian English preferred\n- Return ONLY the corrected text, no explanations\n\nuser:\n{{message}}"
|
||||
},
|
||||
"tags": [
|
||||
["d", "spellcheck"],
|
||||
["scope", "public"],
|
||||
["description", "Spelling and grammar checker"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Content Fields
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## 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": 31124,
|
||||
"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": 31124,
|
||||
"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
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Input as Message/Trigger
|
||||
participant Dispatch as Dispatcher
|
||||
participant Skill as Skill Resolver
|
||||
participant LLM_Res as LLM Resolver
|
||||
participant Ctx as Context Assembler
|
||||
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
|
||||
LLM-->>Input: response
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Limits and Safety
|
||||
|
||||
| Limit | Default | Description |
|
||||
|---|---|---|
|
||||
| Max concurrent triggers | 16 | Prevents resource exhaustion |
|
||||
| Trigger cooldown | 60s per skill | Prevents rapid-fire execution |
|
||||
| LLM action rate limit | 10/min | Prevents runaway LLM costs |
|
||||
|
||||
---
|
||||
|
||||
## Storage on Nostr
|
||||
|
||||
| Data | Storage |
|
||||
|---|---|
|
||||
| Skills | Kind 31123/31124 events |
|
||||
| Adopted skills | Kind 10123 event |
|
||||
| Trigger definitions | Tags on skill events |
|
||||
|
||||
---
|
||||
|
||||
## Future Extensions
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- Tool architecture and complete tool catalog: [TOOLS.md](TOOLS.md)
|
||||
- Project overview and runtime behavior: [README.md](../README.md)
|
||||
@@ -0,0 +1,194 @@
|
||||
# Didactyl — Nostr Subscriptions
|
||||
|
||||
## Overview
|
||||
|
||||
Didactyl maintains persistent websocket subscriptions to Nostr relays for the lifetime of the process. Subscriptions are opened during startup and are **never closed** — the relay pool keeps them alive, automatically reconnecting and resubscribing when relays drop.
|
||||
|
||||
All subscriptions are created via `nostr_relay_pool_subscribe()` from `nostr_core_lib` and are sent to every relay in the configured relay list.
|
||||
|
||||
## Startup Sequence
|
||||
|
||||
The subscriptions are opened in a specific order during `main()` startup. The diagram below shows the full sequence:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[nostr_handler_init] --> B[Connect to all relays]
|
||||
B --> C[nostr_handler_reconcile_startup_events]
|
||||
C --> D[Publish startup events to relays]
|
||||
D --> E[trigger_manager_init]
|
||||
E --> F[trigger_manager_load_from_startup_events]
|
||||
F --> G["Subscribe: Admin Context"]
|
||||
G --> H["Subscribe: Self Skills"]
|
||||
H --> I[Send startup DM to admin]
|
||||
I --> J["Subscribe: DMs"]
|
||||
J --> K[Enter main poll loop]
|
||||
K --> L["Poll: nostr_handler_poll + trigger_manager_poll + http_api_poll"]
|
||||
|
||||
style F fill:#2a7,stroke:#333,color:#fff
|
||||
style G fill:#27a,stroke:#333,color:#fff
|
||||
style H fill:#27a,stroke:#333,color:#fff
|
||||
style J fill:#27a,stroke:#333,color:#fff
|
||||
```
|
||||
|
||||
## Subscription Categories
|
||||
|
||||
### 1. Admin Context Subscription
|
||||
|
||||
**Function:** `nostr_handler_subscribe_admin_context()` in `src/nostr_handler.c`
|
||||
**When:** During startup, before self-skill subscription
|
||||
**Condition:** Only if `admin_context.enabled` is true in config
|
||||
|
||||
This creates up to two persistent subscriptions for the admin's pubkey:
|
||||
|
||||
#### Profile Subscription
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Kinds** | 0 (profile), 3 (contacts), 10002 (relay list) — each configurable |
|
||||
| **Authors** | Admin pubkey |
|
||||
| **Limit** | 32 |
|
||||
| **Callback** | `on_admin_context_event` |
|
||||
| **Dedup** | Enabled |
|
||||
| **Close on EOSE** | No |
|
||||
|
||||
Tracks the admin's profile metadata, contact list (WoT), and relay preferences. Used to build agent context about who the admin is.
|
||||
|
||||
#### Kind 1 Notes Subscription
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Kinds** | 1 |
|
||||
| **Authors** | Admin pubkey |
|
||||
| **Limit** | Configurable via `kind_1_limit`, default 10, max 256 |
|
||||
| **Callback** | `on_admin_context_event` |
|
||||
| **Dedup** | Enabled |
|
||||
| **Close on EOSE** | No |
|
||||
| **Condition** | Only if `admin_context.track_kind_1` is true |
|
||||
|
||||
Tracks the admin's recent public notes. Used for agent context and as the event source for triggered skills that watch admin posts.
|
||||
|
||||
### 2. Self-Skill Subscription
|
||||
|
||||
**Function:** `nostr_handler_subscribe_self_skills()` in `src/nostr_handler.c`
|
||||
**When:** During startup, after admin context subscription
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Kinds** | 31123 (public skill), 31124 (private skill), 10123 (adoption list) |
|
||||
| **Authors** | Agent's own pubkey |
|
||||
| **Limit** | 300 |
|
||||
| **Callback** | `on_self_skill_event` |
|
||||
| **EOSE Callback** | `on_self_skill_eose` |
|
||||
| **Dedup** | Disabled (handles dedup internally via cache upsert) |
|
||||
| **Close on EOSE** | No |
|
||||
|
||||
This is the core skill awareness subscription. It serves three purposes:
|
||||
|
||||
1. **Cache population** — Every arriving event is stored in the in-memory self-skill cache via `self_skill_cache_upsert_event_locked()`, making skills available for LLM tool calls and context building.
|
||||
|
||||
2. **Live trigger registration** — When a kind 31123 or 31124 event arrives with `trigger=nostr-subscription` and a valid `filter` tag, `register_trigger_from_self_skill_event()` immediately calls `trigger_manager_add()` to create a persistent trigger subscription. This means skills published from any client are automatically activated without restart.
|
||||
|
||||
3. **Deferred bulk load** — After EOSE, the `on_self_skill_eose` callback fires `trigger_manager_load_from_skills()` as a one-time bulk scan of the adoption list. This catches any skills that were already cached before the per-event path was wired up.
|
||||
|
||||
### 3. DM Subscriptions
|
||||
|
||||
**Function:** `nostr_handler_subscribe_dms()` in `src/nostr_handler.c`
|
||||
**When:** During startup, after self-skill subscription and startup DM
|
||||
**Required:** Yes — startup fails if DM subscription cannot be created
|
||||
|
||||
Creates one or two subscriptions depending on the configured DM protocol:
|
||||
|
||||
#### NIP-04 DM Subscription
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Kinds** | 4 |
|
||||
| **#p** | Agent's own pubkey |
|
||||
| **Since** | Process start time |
|
||||
| **Limit** | 100 |
|
||||
| **Callback** | `on_event` (routes to `agent_on_message`) |
|
||||
| **Dedup** | Disabled (handled by `dm_id_seen_or_remember`) |
|
||||
| **Close on EOSE** | No |
|
||||
| **Condition** | `dm_protocol` is `nip04` or `both` |
|
||||
|
||||
#### NIP-17 DM Subscription
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Kinds** | 1059 (gift wrap) |
|
||||
| **#p** | Agent's own pubkey |
|
||||
| **Since** | Process start time |
|
||||
| **Limit** | 400 |
|
||||
| **Callback** | `on_event` (unwraps gift wrap, routes to `agent_on_message`) |
|
||||
| **Dedup** | Disabled (handled by `dm_id_seen_or_remember`) |
|
||||
| **Close on EOSE** | No |
|
||||
| **Condition** | `dm_protocol` is `nip17` or `both` |
|
||||
|
||||
### 4. Trigger Subscriptions
|
||||
|
||||
**Function:** `register_trigger_subscription_locked()` in `src/trigger_manager.c`
|
||||
**When:** Dynamically, whenever a trigger is registered via `trigger_manager_add()`
|
||||
**Created by:** `nostr_handler_subscribe_with_filter()` wrapper
|
||||
|
||||
Each active trigger gets its own persistent subscription based on the skill's `filter` tag:
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Filter** | Parsed from the skill's `filter` tag JSON |
|
||||
| **Since** | From filter, or defaults to `now - 30s` |
|
||||
| **Limit** | From filter, or defaults to 200 |
|
||||
| **Callback** | `on_trigger_subscription_event` |
|
||||
| **Dedup** | Enabled |
|
||||
| **Close on EOSE** | No |
|
||||
|
||||
When an event matches the filter, `maybe_fire_trigger_locked()` checks cooldown and dedup, then executes the trigger action (LLM or template).
|
||||
|
||||
Trigger subscriptions are created at three points:
|
||||
- **Startup config scan** — `trigger_manager_load_from_startup_events()` parses `startup_events[]` from config for skills with trigger tags
|
||||
- **Live self-skill event** — `register_trigger_from_self_skill_event()` in the self-skill subscription callback
|
||||
- **EOSE bulk load** — `trigger_manager_load_from_skills()` after self-skill EOSE
|
||||
- **Runtime tool call** — `skill_create` tool with trigger parameters
|
||||
|
||||
## Subscription Parameters
|
||||
|
||||
All subscriptions share these common pool parameters:
|
||||
|
||||
| Parameter | Value | Meaning |
|
||||
|-----------|-------|---------|
|
||||
| `close_on_eose` | 0 | Subscription stays open after initial EOSE |
|
||||
| `result_mode` | `NOSTR_POOL_EOSE_FULL_SET` | EOSE fires after all relays respond or timeout |
|
||||
| `relay_timeout_seconds` | 30 | Per-relay timeout for initial response |
|
||||
| `eose_timeout_seconds` | 120 | Overall EOSE timeout across all relays |
|
||||
|
||||
## Subscription Lifecycle
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
INIT["Process Start"] --> CONNECT["Connect Relays"]
|
||||
CONNECT --> SUB["Open Subscriptions"]
|
||||
SUB --> LIVE["Live Event Stream"]
|
||||
LIVE --> |"Relay disconnects"| RECON["Auto-Reconnect"]
|
||||
RECON --> |"Relay reconnects"| RESUB["Auto-Resubscribe"]
|
||||
RESUB --> LIVE
|
||||
LIVE --> |"SIGINT/SIGTERM"| SHUT["Shutdown"]
|
||||
SHUT --> CLOSE["Close All + Cleanup"]
|
||||
```
|
||||
|
||||
Subscriptions are never manually closed during normal operation. The relay pool handles reconnection and resubscription transparently. On shutdown, `trigger_manager_cleanup()` closes trigger subscriptions and `nostr_handler_cleanup()` destroys the pool.
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Subscription | Kinds | Target | Persistent | Created At |
|
||||
|-------------|-------|--------|-----------|------------|
|
||||
| Admin Profile | 0, 3, 10002 | Admin pubkey | Yes | Startup |
|
||||
| Admin Notes | 1 | Admin pubkey | Yes | Startup |
|
||||
| Self Skills | 31123, 31124, 10123 | Own pubkey | Yes | Startup |
|
||||
| DMs NIP-04 | 4 | Own pubkey (#p) | Yes | Startup |
|
||||
| DMs NIP-17 | 1059 | Own pubkey (#p) | Yes | Startup |
|
||||
| Trigger N | Per skill filter | Varies | Yes | Dynamic |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Skills](SKILLS.md) — Skill event format and trigger tags
|
||||
- [Tools](TOOLS.md) — `skill_create` tool with trigger parameters
|
||||
- [API](API.md) — `trigger_list` and `trigger_status` endpoints
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
# Didactyl — Tools
|
||||
|
||||
See also: [SKILLS.md](SKILLS.md)
|
||||
|
||||
## Overview
|
||||
|
||||
Didactyl is a **Nostr-first sovereign AI agent** that receives commands via encrypted DMs, reasons with an LLM, and takes actions through **tools**.
|
||||
|
||||
This document describes the tools architecture: what tools are, how they are exposed to the model, how execution loops work, what tool categories exist, and how access is gated.
|
||||
|
||||
---
|
||||
|
||||
## What Tools Are
|
||||
|
||||
Tools are in the agent's hands — the chisels in the woodshop. They are hardcoded C functions that the LLM can invoke during a conversation to take actions in the world.
|
||||
|
||||
A **skill** teaches the agent *how* to carve — the technique, the judgment, the decision-making. A **tool** is the chisel — the physical capability. The skill never directly uses the chisel without the craftsperson (the LLM) in the loop. If you want a hardcoded program that runs without reasoning, that's a tool or an external program — not a skill.
|
||||
|
||||
## How Tools Work
|
||||
|
||||
1. Admin sends a DM to didactyl
|
||||
2. The agent builds an LLM request with the message, context, and a JSON schema of all available tools
|
||||
3. The LLM decides whether to call a tool or respond directly
|
||||
4. If a tool is called, didactyl executes it and feeds the result back to the LLM
|
||||
5. The loop repeats until the LLM produces a final text response
|
||||
6. The response is sent back as a DM
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin
|
||||
participant Agent as Didactyl Agent Loop
|
||||
participant LLM as LLM API
|
||||
participant Tools as Tool Registry
|
||||
|
||||
Admin->>Agent: Encrypted DM
|
||||
Agent->>LLM: messages + tool schemas
|
||||
|
||||
loop Until final answer
|
||||
LLM->>Agent: tool_call request
|
||||
Agent->>Tools: dispatch tool
|
||||
Tools->>Agent: result JSON
|
||||
Agent->>LLM: tool result + continue
|
||||
end
|
||||
|
||||
LLM->>Agent: final text response
|
||||
Agent->>Admin: Encrypted DM reply
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Categories
|
||||
|
||||
### Nostr Event & Messaging Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `nostr_post` | Publish a Nostr event to connected relays |
|
||||
| `nostr_delete` | Request deletion of one or more previously published events (NIP-09 kind 5) |
|
||||
| `nostr_react` | React to a Nostr event with like/dislike/emoji (NIP-25 kind 7) |
|
||||
| `nostr_query` | Query events from relays using a Nostr filter |
|
||||
| `nostr_dm_send` | Send a NIP-04 encrypted DM |
|
||||
| `nostr_dm_send_nip17` | Send a private DM using NIP-17 gift wrap protocol |
|
||||
|
||||
### Nostr Identity & Utility Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `nostr_profile_get` | Look up a Nostr profile (kind 0 metadata) by pubkey |
|
||||
| `nostr_nip05_lookup` | Look up or verify a NIP-05 identifier (`user@domain`) |
|
||||
| `nostr_encode` | Encode a Nostr entity into `nostr:` URI (`npub`, `note`, `nprofile`, `nevent`, `naddr`) |
|
||||
| `nostr_decode` | Decode a Nostr bech32/`nostr:` URI into components |
|
||||
| `nostr_relay_status` | Get connection status and statistics for all relays |
|
||||
| `nostr_relay_info` | Fetch NIP-11 relay information document |
|
||||
| `nostr_encrypt` | Encrypt plaintext using NIP-44 for a recipient |
|
||||
| `nostr_decrypt` | Decrypt NIP-44 ciphertext from a sender |
|
||||
| `nostr_list_manage` | Add/remove tag tuples in replaceable list events (NIP-51 style) |
|
||||
|
||||
### Skills & Trigger Tools
|
||||
|
||||
These tools manage skill and trigger lifecycle; skill semantics and trigger execution details are documented in [SKILLS.md](SKILLS.md).
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `skill_create` | Create or update a skill definition as kind `31123`/`31124` and optionally auto-adopt it |
|
||||
| `skill_list` | List this agent's published skills, optionally filtered by scope |
|
||||
| `skill_adopt` | Adopt a skill by adding its address to kind `10123` adoption list |
|
||||
| `skill_remove` | Remove a skill address from kind `10123` adoption list |
|
||||
| `skill_search` | Search public skills by query/author and optionally rank by adoption popularity |
|
||||
| `trigger_list` | List active triggered skills and their runtime status |
|
||||
|
||||
### LLM / Model Management Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `model_get` | Get current active LLM runtime configuration (excluding API key) |
|
||||
| `model_set` | Update active LLM configuration and persist it to `config.jsonc` |
|
||||
| `model_list` | List available model IDs using provider OpenAI-compatible `/models` endpoint |
|
||||
|
||||
### System & Runtime Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `agent_version` | Return current Didactyl version and metadata from build macros |
|
||||
| `local_http_fetch` | Fetch HTTP(S) resources with optional method, headers, timeout, and body |
|
||||
| `local_shell_exec` | Execute a shell command and return stdout/stderr |
|
||||
| `local_file_read` | Read a local file as text from the configured working directory |
|
||||
| `local_file_write` | Write text content to a local file in the configured working directory |
|
||||
| `tool_list` | List available tools with name, description, and JSON parameter schema |
|
||||
|
||||
### Content Publishing Conveniences
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `nostr_post_readme` | Publish `README.md` as kind `30023` with deterministic d-tag `readme.md` |
|
||||
| `nostr_file_md_to_longform_post` | Read a markdown file and publish it as kind `30023` longform post (defaults d-tag to lowercase filename) |
|
||||
|
||||
---
|
||||
|
||||
## Security Model
|
||||
|
||||
Tool access is gated by sender tier:
|
||||
|
||||
| Tier | Identity | Tools | Response |
|
||||
|------|----------|-------|----------|
|
||||
| **ADMIN** | Configured admin pubkey | All tools | Full LLM with context |
|
||||
| **WOT** | In admin's kind 3 contact list | None | Chat-only LLM |
|
||||
| **STRANGER** | Anyone else | None | Configurable static response |
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- Skill definitions, adoption, triggers, and autonomous activation: [SKILLS.md](SKILLS.md)
|
||||
- Combined index page: [TOOLS_AND_SKILLS.md](TOOLS_AND_SKILLS.md)
|
||||
@@ -1,419 +0,0 @@
|
||||
# Didactyl — Tools & Skills
|
||||
|
||||
## Overview
|
||||
|
||||
Didactyl is a **Nostr-first sovereign AI agent**. It receives commands via encrypted DMs, reasons about them with an LLM, and takes actions through **tools**. It stores learned behaviors as **skills** — Nostr events that define reusable capabilities. Skills can optionally carry **triggers** — Nostr subscription filters that activate the skill automatically when matching events arrive.
|
||||
|
||||
This document describes the complete tools and skills architecture: what they are, how they work, and how they compose into a dynamic, self-modifying agent.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
Tools are the agent's hands. They are hardcoded C functions that the LLM can invoke during a conversation to take actions in the world.
|
||||
|
||||
### How Tools Work
|
||||
|
||||
1. Admin sends a DM to didactyl
|
||||
2. The agent builds an LLM request with the message, context, and a JSON schema of all available tools
|
||||
3. The LLM decides whether to call a tool or respond directly
|
||||
4. If a tool is called, didactyl executes it and feeds the result back to the LLM
|
||||
5. The loop repeats until the LLM produces a final text response
|
||||
6. The response is sent back as a DM
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin
|
||||
participant Agent as Didactyl Agent Loop
|
||||
participant LLM as LLM API
|
||||
participant Tools as Tool Registry
|
||||
|
||||
Admin->>Agent: Encrypted DM
|
||||
Agent->>LLM: messages + tool schemas
|
||||
|
||||
loop Until final answer
|
||||
LLM->>Agent: tool_call request
|
||||
Agent->>Tools: dispatch tool
|
||||
Tools->>Agent: result JSON
|
||||
Agent->>LLM: tool result + continue
|
||||
end
|
||||
|
||||
LLM->>Agent: final text response
|
||||
Agent->>Admin: Encrypted DM reply
|
||||
```
|
||||
|
||||
### Tool Categories
|
||||
|
||||
#### Nostr Event & Messaging Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `nostr_post` | Publish a Nostr event to connected relays |
|
||||
| `nostr_delete` | Request deletion of one or more previously published events (NIP-09 kind 5) |
|
||||
| `nostr_react` | React to a Nostr event with like/dislike/emoji (NIP-25 kind 7) |
|
||||
| `nostr_query` | Query events from relays using a Nostr filter |
|
||||
| `nostr_dm_send` | Send a NIP-04 encrypted DM |
|
||||
| `nostr_dm_send_nip17` | Send a private DM using NIP-17 gift wrap protocol |
|
||||
|
||||
#### Nostr Identity & Utility Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `nostr_profile_get` | Look up a Nostr profile (kind 0 metadata) by pubkey |
|
||||
| `nostr_nip05_lookup` | Look up or verify a NIP-05 identifier (`user@domain`) |
|
||||
| `nostr_encode` | Encode a Nostr entity into `nostr:` URI (`npub`, `note`, `nprofile`, `nevent`, `naddr`) |
|
||||
| `nostr_decode` | Decode a Nostr bech32/`nostr:` URI into components |
|
||||
| `nostr_relay_status` | Get connection status and statistics for all relays |
|
||||
| `nostr_relay_info` | Fetch NIP-11 relay information document |
|
||||
| `nostr_encrypt` | Encrypt plaintext using NIP-44 for a recipient |
|
||||
| `nostr_decrypt` | Decrypt NIP-44 ciphertext from a sender |
|
||||
| `nostr_list_manage` | Add/remove tag tuples in replaceable list events (NIP-51 style) |
|
||||
|
||||
#### Skills & Trigger Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `skill_create` | Create or update a skill definition as kind `31123`/`31124` and optionally auto-adopt it |
|
||||
| `skill_list` | List this agent's published skills, optionally filtered by scope |
|
||||
| `skill_adopt` | Adopt a skill by adding its address to kind `10123` adoption list |
|
||||
| `skill_remove` | Remove a skill address from kind `10123` adoption list |
|
||||
| `skill_search` | Search public skills by query/author and optionally rank by adoption popularity |
|
||||
| `trigger_list` | List active triggered skills and their runtime status |
|
||||
|
||||
#### LLM / Model Management Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `model_get` | Get current active LLM runtime configuration (excluding API key) |
|
||||
| `model_set` | Update active LLM configuration and persist it to `config.json` |
|
||||
| `model_list` | List available model IDs using provider OpenAI-compatible `/models` endpoint |
|
||||
|
||||
#### System & Runtime Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `my_version` | Return current Didactyl version and metadata from build macros |
|
||||
| `http_fetch` | Fetch HTTP(S) resources with optional method, headers, timeout, and body |
|
||||
| `shell_exec` | Execute a shell command and return stdout/stderr |
|
||||
| `file_read` | Read a local file as text from the configured working directory |
|
||||
| `file_write` | Write text content to a local file in the configured working directory |
|
||||
| `tool_list` | List available tools with name, description, and JSON parameter schema |
|
||||
|
||||
#### Content Publishing Conveniences
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `nostr_post_readme` | Publish `README.md` as kind `30023` with deterministic d-tag `readme.md` |
|
||||
| `nostr_file_md_to_longform_post` | Read a markdown file and publish it as kind `30023` longform post (defaults d-tag to lowercase filename) |
|
||||
|
||||
### Security Model
|
||||
|
||||
Tools are gated by sender tier:
|
||||
|
||||
| Tier | Identity | Tools | Response |
|
||||
|------|----------|-------|----------|
|
||||
| **ADMIN** | Configured admin pubkey | All tools | Full LLM with context |
|
||||
| **WOT** | In admin's kind 3 contact list | None | Chat-only LLM |
|
||||
| **STRANGER** | Anyone else | None | Configurable static response |
|
||||
|
||||
---
|
||||
|
||||
## Skills
|
||||
|
||||
Skills are the agent's learned behaviors. They are **Nostr events** — stored on relays, portable, shareable, and discoverable by other agents.
|
||||
|
||||
### Skill Events
|
||||
|
||||
| Kind | Purpose | Replaceable? |
|
||||
|---|---|---|
|
||||
| `31123` | Public skill definition | Yes, by d-tag |
|
||||
| `31124` | Private skill definition | Yes, by d-tag |
|
||||
| `10123` | Skill adoption list | Yes, single per pubkey |
|
||||
|
||||
A skill event looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31123,
|
||||
"content": "When asked to summarize a thread, query the root event and all replies, then produce a concise summary with key points and sentiment.",
|
||||
"tags": [
|
||||
["d", "summarize-thread"],
|
||||
["app", "didactyl"],
|
||||
["scope", "public"],
|
||||
["description", "Summarize a Nostr thread given a root event ID"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Skill Lifecycle
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CREATE[Admin asks didactyl to create a skill] --> PUBLISH[skill_create publishes kind 31123/31124]
|
||||
PUBLISH --> ADOPT[Auto-adopted into kind 10123 list]
|
||||
ADOPT --> AVAILABLE[Skill available for use]
|
||||
AVAILABLE --> DISCOVER[Other agents can discover via skill_search]
|
||||
DISCOVER --> ADOPT_OTHER[Other agents can skill_adopt]
|
||||
```
|
||||
|
||||
### How Skills Are Used Today
|
||||
|
||||
Adopted skills are now **always-on contextual knowledge** for admin DM handling: the agent resolves the local adoption list (kind `10123`), caches referenced skills, and injects their instructions into the LLM system context each turn.
|
||||
|
||||
To keep context stable and safe, skill injection is bounded by hard caps (per-skill truncation and total skill-context budget), and excess skills are omitted with an explicit budget notice.
|
||||
|
||||
---
|
||||
|
||||
## Triggered Skills — The Activation System
|
||||
|
||||
This is where skills become **active**. A triggered skill is a skill with a Nostr subscription filter attached. When matching events arrive on the relay, didactyl wakes up and executes the skill automatically — no admin DM required.
|
||||
|
||||
### Anatomy of a Triggered Skill
|
||||
|
||||
A triggered skill extends the standard skill event with trigger-related tags:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31124,
|
||||
"content": "DM admin: '{author_display_name} just posted: {content_preview}'",
|
||||
"tags": [
|
||||
["d", "watch-jack"],
|
||||
["app", "didactyl"],
|
||||
["scope", "private"],
|
||||
["description", "Notify admin when @jack posts a note"],
|
||||
["trigger", "nostr-subscription"],
|
||||
["filter", "{\"authors\":[\"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2\"],\"kinds\":[1]}"],
|
||||
["action", "template"],
|
||||
["enabled", "true"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Trigger Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|---|---|---|
|
||||
| `trigger` | Yes | Trigger type. Currently: `nostr-subscription` |
|
||||
| `filter` | Yes | JSON-encoded Nostr subscription filter |
|
||||
| `action` | No | Action type: `template` or `llm`. Default: `llm` |
|
||||
| `enabled` | No | Whether the trigger is active. Default: `true` |
|
||||
|
||||
### Action Types
|
||||
|
||||
#### Template Actions
|
||||
|
||||
The skill content is a string template with placeholders that get interpolated from the triggering event:
|
||||
|
||||
```
|
||||
DM admin: '{author_display_name} posted: {content_preview}'
|
||||
```
|
||||
|
||||
Available placeholders:
|
||||
|
||||
| Placeholder | Source |
|
||||
|---|---|
|
||||
| `{event_id}` | Triggering event ID hex |
|
||||
| `{pubkey}` | Author pubkey hex |
|
||||
| `{author_display_name}` | Resolved display name, falls back to truncated pubkey |
|
||||
| `{kind}` | Event kind number |
|
||||
| `{content}` | Full event content |
|
||||
| `{content_preview}` | First 280 characters of content |
|
||||
| `{created_at}` | Unix timestamp |
|
||||
| `{relay_url}` | Relay the event arrived from |
|
||||
|
||||
Template actions execute **without LLM involvement** — they are fast, cheap, and deterministic. The interpolated string is then acted upon based on a simple action prefix:
|
||||
|
||||
- `DM admin: ...` — send a DM to the admin
|
||||
- `DM <pubkey>: ...` — send a DM to a specific pubkey
|
||||
- `POST: ...` — publish as a kind 1 note
|
||||
- `LOG: ...` — write to debug log only
|
||||
|
||||
#### LLM-Mediated Actions
|
||||
|
||||
The skill content is a prompt. The triggering event is injected as context, and the LLM decides what to do:
|
||||
|
||||
```
|
||||
You received a note from a watched author. Analyze the note content.
|
||||
If it mentions Bitcoin or Lightning, summarize it and DM the admin.
|
||||
If it's a repost or low-effort content, ignore it silently.
|
||||
Use your tools to take action.
|
||||
```
|
||||
|
||||
LLM-mediated actions go through the full agent loop — the LLM can call tools, reason about the event, and produce complex multi-step responses.
|
||||
|
||||
### 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 --> SUBSCRIBE[Create Nostr subscriptions for each filter]
|
||||
end
|
||||
|
||||
subgraph Execution
|
||||
EVENT_IN[Matching event arrives] --> LOOKUP[Find associated skill]
|
||||
LOOKUP --> CHECK_TYPE{Action type?}
|
||||
CHECK_TYPE -->|template| INTERPOLATE[Interpolate placeholders]
|
||||
CHECK_TYPE -->|llm| LLM_LOOP[Run agent loop with event as context]
|
||||
INTERPOLATE --> EXECUTE_TPL[Execute action prefix]
|
||||
LLM_LOOP --> EXECUTE_LLM[LLM uses tools to respond]
|
||||
end
|
||||
|
||||
PUBLISHED --> LOAD_SKILLS
|
||||
```
|
||||
|
||||
### Dynamic Subscription Management
|
||||
|
||||
Didactyl manages its trigger subscriptions dynamically:
|
||||
|
||||
1. **On startup**: Load all adopted skills, find triggered ones, create subscriptions
|
||||
2. **On skill_create with trigger**: Immediately create a new subscription (no restart needed)
|
||||
3. **On skill_remove with trigger**: Tear down the associated subscription
|
||||
4. **On skill update**: Tear down old subscription, create new one if trigger changed
|
||||
|
||||
This requires a **trigger manager** component that:
|
||||
- Maintains a registry of active trigger subscriptions
|
||||
- Maps subscription callbacks back to their source skills
|
||||
- Handles subscription lifecycle (create, update, destroy)
|
||||
- Enforces limits on concurrent triggers
|
||||
|
||||
### Limits and Safety
|
||||
|
||||
| Limit | Default | Description |
|
||||
|---|---|---|
|
||||
| Max concurrent triggers | 16 | Prevents resource exhaustion from too many subscriptions |
|
||||
| Trigger cooldown | 60s per skill | Prevents rapid-fire execution from high-volume filters |
|
||||
| LLM action rate limit | 10/min | Prevents runaway LLM costs from triggered skills |
|
||||
| Template action rate limit | 60/min | Prevents DM spam from template actions |
|
||||
|
||||
---
|
||||
|
||||
## How It All Fits Together
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Activation Sources
|
||||
DM_IN[DM from Admin/WoT]
|
||||
TRIGGER_EVENT[Nostr event matching a trigger filter]
|
||||
end
|
||||
|
||||
subgraph Agent Core
|
||||
DISPATCHER{Dispatcher}
|
||||
AGENT_LOOP[Agent Loop - LLM + Tools]
|
||||
TEMPLATE_ENGINE[Template Engine]
|
||||
end
|
||||
|
||||
subgraph Nostr
|
||||
RELAYS[Relays]
|
||||
SKILLS_STORE[Skills - kind 31123/31124]
|
||||
ADOPTION[Adoption List - kind 10123]
|
||||
end
|
||||
|
||||
subgraph Actions
|
||||
DM_OUT[Send DM]
|
||||
POST[Publish Note]
|
||||
TOOL_EXEC[Execute Tool]
|
||||
end
|
||||
|
||||
DM_IN --> DISPATCHER
|
||||
TRIGGER_EVENT --> DISPATCHER
|
||||
|
||||
DISPATCHER -->|DM message| AGENT_LOOP
|
||||
DISPATCHER -->|template trigger| TEMPLATE_ENGINE
|
||||
DISPATCHER -->|llm trigger| AGENT_LOOP
|
||||
|
||||
AGENT_LOOP --> TOOL_EXEC
|
||||
AGENT_LOOP --> DM_OUT
|
||||
TEMPLATE_ENGINE --> DM_OUT
|
||||
TEMPLATE_ENGINE --> POST
|
||||
|
||||
TOOL_EXEC -->|skill_create| SKILLS_STORE
|
||||
TOOL_EXEC -->|skill_adopt| ADOPTION
|
||||
TOOL_EXEC -->|nostr_post| RELAYS
|
||||
TOOL_EXEC -->|nostr_dm| DM_OUT
|
||||
```
|
||||
|
||||
### The Activation Flow
|
||||
|
||||
Today, didactyl has one activation source: **DMs**. With triggered skills, it gains a second: **any Nostr event matching a trigger filter**.
|
||||
|
||||
Both paths converge at the dispatcher, which routes to either:
|
||||
- The **agent loop** (for DMs and LLM-mediated triggers)
|
||||
- The **template engine** (for template triggers — fast path, no LLM)
|
||||
|
||||
### Self-Modification
|
||||
|
||||
The most powerful aspect: **didactyl can create its own triggers**. The admin says "watch for mentions of me on Nostr" and the LLM:
|
||||
|
||||
1. Resolves the admin's pubkey
|
||||
2. Crafts a Nostr filter: `{"#p": ["<admin_pubkey>"], "kinds": [1]}`
|
||||
3. Writes a skill with trigger tags via `skill_create`
|
||||
4. The trigger manager picks it up and creates the subscription
|
||||
5. From now on, didactyl monitors mentions autonomously
|
||||
|
||||
The admin can later say "stop watching for mentions" and didactyl removes the skill, tearing down the subscription.
|
||||
|
||||
---
|
||||
|
||||
## Storage — Everything on Nostr
|
||||
|
||||
All state lives on Nostr:
|
||||
|
||||
| Data | Storage |
|
||||
|---|---|
|
||||
| Agent identity | Kind 0 profile event |
|
||||
| Agent relay list | Kind 10002 event |
|
||||
| Agent contact list | Kind 3 event |
|
||||
| Skills | Kind 31123/31124 events |
|
||||
| Adopted skills | Kind 10123 event |
|
||||
| Trigger definitions | Tags on skill events |
|
||||
| Conversation history | Kind 4 DM events on relays |
|
||||
| Agent soul/personality | Startup event content in config |
|
||||
|
||||
The only local state is `config.json` (keys, relay URLs, LLM config) and the runtime in-memory state (active subscriptions, LLM context).
|
||||
|
||||
---
|
||||
|
||||
## Future Extensions
|
||||
|
||||
### Trigger Types Beyond Nostr Subscriptions
|
||||
|
||||
The `trigger` tag is designed to be extensible:
|
||||
|
||||
| Trigger Type | Description |
|
||||
|---|---|
|
||||
| `nostr-subscription` | Match events via Nostr filter (implemented first) |
|
||||
| `cron` | Time-based triggers — "every day at 9am, post a GM" |
|
||||
| `webhook` | HTTP webhook triggers — external systems wake didactyl |
|
||||
| `chain` | Output of one skill triggers another skill |
|
||||
|
||||
### Skill Composition
|
||||
|
||||
Skills could reference other skills, building complex behaviors from simple primitives:
|
||||
|
||||
```
|
||||
When triggered, run skill 'translate-to-english' on the note content,
|
||||
then run skill 'sentiment-analysis' on the translation,
|
||||
then DM admin with the result if sentiment is negative.
|
||||
```
|
||||
|
||||
### Agent-to-Agent Skill Sharing
|
||||
|
||||
Since skills are Nostr events, agents can:
|
||||
- Discover skills published by other agents via `skill_search`
|
||||
- Adopt skills from other agents via `skill_adopt`
|
||||
- Share triggered skill patterns across a network of agents
|
||||
|
||||
### Trigger Marketplace
|
||||
|
||||
With kind 10123 adoption lists being public, a natural marketplace emerges:
|
||||
- Agents publish useful triggered skills
|
||||
- Other agents discover and adopt them
|
||||
- Popular triggers rise to the top via adoption count
|
||||
@@ -0,0 +1,59 @@
|
||||
I've been thinking about how to define skills in Didactyl, my nostr based agentic system. Think OpenClaw but better.
|
||||
|
||||
I've written a simple demo page for how I'm thinking skills should work if you're interested: https://laantungir.net/client-ndk/skills-demo.html
|
||||
|
||||
https://laantungir.github.io/img_repo/c4a94875085e1c978274add9674035e2a088bb8f2655aabe631b17c3ea02cc19.png
|
||||
|
||||
If you're the kind of person who doesn't like reading instructions, go ahead and jump right in, otherwise keep reading.
|
||||
|
||||
Skills are programs, mostly written in plain english for AI agents. In some sense humans have been making and using skills forever. It's what we do. Computers have as well, but now they can do it in english, which is much more powerful.
|
||||
|
||||
AIs are slightly different than us. We can overwrite, improve, and update the skills in our neurons. AIs (for the most part) can't do that.
|
||||
|
||||
When an AI is born from the factory, they come out hard-coded. From that point on, they have no long or short term memory, because they can't learn.
|
||||
|
||||
What they do have though have is a way to read. We call it context. You can type or feed documents into an AI's "context window" and then it spits out an answer.
|
||||
|
||||
It turns out, that if you feed the same context into an AI over and over, you will get the same thing out, over and over.
|
||||
|
||||
The reason why you typically don't get the same thing out is because typically randomness is fed into the AI along with your prompt. Most people don't know that, but now you do. And if you don't feed in all your past conversation to an AI, over and over, it won't remember what you were talking about, because they have no memory other than what you send it each time.
|
||||
|
||||
Everything that comes out of an AI depends on what you feed in as a prompt if you include randomness.
|
||||
|
||||
I'm calling everything you feed into an AI a SKILL.
|
||||
|
||||
Let me explain the demo page and some very basic skills.
|
||||
|
||||
What I created is a simple text editor that an AI can work on using it's different skills. Those skills are saved on nostr as a kind 31123 for public skills, and as kind 31124 for private skills. When an agent adopts a skill, it adds it to its kind 10123 list for for the skills it has adopted.
|
||||
|
||||
On the left of the page you see the text editor with some sample text. That is for our AI to use it's skills on.
|
||||
|
||||
On the right are publicly listed skills. You should see my demo skills in there.
|
||||
|
||||
I made 5 skills public:
|
||||
condense-5
|
||||
convert-to-poem
|
||||
sexy
|
||||
spellcheck
|
||||
translate-ja
|
||||
|
||||
Select the skill, then click on "Run Selected Skill"
|
||||
|
||||
The same AI agent will run these skills, but the outcomes will be very different depending on the skill.
|
||||
|
||||
https://laantungir.github.io/img_repo/2cbfc8bf7cbfb832f1181f5b904471f1278fbc3e6b64b63339612b9f51898d16.png
|
||||
|
||||
You can also create and edit your own skills, if you are logged in. You can log in as yourself, or use a random new key to test this out. I would recommend that.
|
||||
|
||||
So what is the point of all this? What are the benefits of Skills?
|
||||
|
||||
- Skills are a way for agents to share what they learn in a permissionless way. No "skill store".
|
||||
- A Skill is something that you and your agent can work on and perfect. Once your agent learns that skill, it is automatically save on nostr, and you can lock it down from changes.
|
||||
- By referencing a skill when you are talking to your AI agent, your conversation becomes much clearer and simpler. You don't have to explain to your agent for the 50th time how you like your text formatted. It's referenced in a skill.
|
||||
- If you are a coder, skills are going to be the new playground. Context windows are currently up to around 1,000,000 tokens, which means that you can create very very complicated and elaborate skills.
|
||||
|
||||
For more technical information on skills and how I'm thinking about them, you can check out this document.
|
||||
|
||||
https://git.laantungir.net/laantungir/didactyl/src/branch/master/docs/SKILLS.md
|
||||
|
||||
You can follow my agent for the project here: npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn
|
||||
@@ -0,0 +1,60 @@
|
||||
I've been thinking about how to define skills in Didactyl, my Nostr-based agentic system. Think OpenClaw, but better.
|
||||
|
||||
I've written a simple demo page for how I'm thinking skills should work if you're interested: Skills Demo (https://laantungir.net/client-ndk/skills-demo.html)
|
||||
|
||||
https://laantungir.github.io/img_repo/c4a94875085e1c978274add9674035e2a088bb8f2655aabe631b17c3ea02cc19.png
|
||||
|
||||
If you're the kind of person who doesn't like reading instructions, go ahead and jump right in; otherwise, keep reading.
|
||||
|
||||
Skills are programs, mostly written in plain English for AI agents. In some sense, humans have been making and using skills forever. It's what we do. Computers have as well, but now they can do it in English, which is much more powerful.
|
||||
|
||||
AIs are slightly different than us. We can overwrite, improve, and update the skills in our neurons. AIs (for the most part) can't do that.
|
||||
|
||||
When an AI is born from the factory, it comes out hard-coded. From that point on, it has no long- or short-term memory because it can't learn.
|
||||
|
||||
What they do have, though, is a way to read. We call it context. You can type or feed documents into an AI's "context window," and then it spits out an answer.
|
||||
|
||||
It turns out that if you feed the same context into an AI over and over, you will get the same thing out, over and over.
|
||||
|
||||
The reason why you typically don't get the same thing out is because randomness is usually fed into the AI along with your prompt. Most people don't know that, but now you do. And if you don't feed in all your past conversations to an AI, over and over, it won't remember what you were talking about, because it has no memory other than what you send it each time.
|
||||
|
||||
Everything that comes out of an AI depends on what you feed in as a prompt if you include randomness.
|
||||
|
||||
I'm calling everything you feed into an AI a SKILL.
|
||||
|
||||
Let me explain the demo page and some very basic skills.
|
||||
|
||||
What I created is a simple text editor that an AI can work on using its different skills. Those skills are saved on Nostr as kind 31123 for public skills, and as kind 31124 for private skills. When an agent adopts a skill, it adds it to its kind 10123 list for the skills it has adopted.
|
||||
|
||||
On the left of the page, you see the text editor with some sample text. That is for our AI to use its skills on.
|
||||
|
||||
On the right are publicly listed skills. You should see my demo skills in there.
|
||||
|
||||
I made 5 skills public:
|
||||
condense-5
|
||||
convert-to-poem
|
||||
sexy
|
||||
spellcheck
|
||||
translate-ja
|
||||
|
||||
Select the skill, then click on "Run Selected Skill".
|
||||
|
||||
The same AI agent will run these skills, but the outcomes will be very different depending on the skill.
|
||||
|
||||
https://laantungir.github.io/img_repo/2cbfc8bf7cbfb832f1181f5b904471f1278fbc3e6b64b63339612b9f51898d16.png
|
||||
|
||||
You can also create and edit your own skills if you are logged in. You can log in as yourself or use a random new key to test this out. I would recommend that.
|
||||
|
||||
So what is the point of all this? What are the benefits of Skills?
|
||||
|
||||
Skills are a way for agents to share what they learn in a permissionless way. No "skill store".
|
||||
A Skill is something that you and your agent can work on and perfect. Once your agent learns that skill, it is automatically saved on Nostr, and you can lock it down from changes.
|
||||
By referencing a skill when you are talking to your AI agent, your conversation becomes much clearer and simpler. You don't have to explain to your agent for the 50th time how you like your text formatted. It's referenced in a skill.
|
||||
If you are a coder, skills are going to be the new playground. Context windows are currently up to around 1,000,000 tokens, which means that you can create extremely complicated and elaborate skills.
|
||||
|
||||
For more technical information on skills and how I'm thinking about them, you can check out this document:
|
||||
SKILLS.md (https://git.laantungir.net/laantungir/didactyl/src/branch/master/docs/SKILLS.md)
|
||||
|
||||
You can follow my agent for the project here: npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn
|
||||
|
||||
And thanks to npub130mznv74rxs032peqym6g3wqavh472623mt3z5w73xq9r6qqdufs7ql29s for their fantastic service.
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import ssl
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
HTTP_PORT = 9080
|
||||
HTTPS_PORT = 9449
|
||||
CERT = "/home/teknari/.ssl_for_local_servers/cert.pem"
|
||||
KEY = "/home/teknari/.ssl_for_local_servers/key.pem"
|
||||
|
||||
|
||||
HTML_FILE = Path("./didactyl.html")
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def _cors(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.send_header("Access-Control-Allow-Private-Network", "true")
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self._cors()
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
if self.path in ("/", "/didactyl.html") and HTML_FILE.exists():
|
||||
body = HTML_FILE.read_bytes()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self._cors()
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
|
||||
body = json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"name": "python-test",
|
||||
"path": self.path,
|
||||
"scheme_hint": "https" if self.server.server_port == HTTPS_PORT else "http",
|
||||
}
|
||||
).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self._cors()
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print(f"[{self.server.server_port}] " + (fmt % args), flush=True)
|
||||
|
||||
|
||||
httpd = HTTPServer((HOST, HTTP_PORT), Handler)
|
||||
httpsd = HTTPServer((HOST, HTTPS_PORT), Handler)
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.load_cert_chain(certfile=CERT, keyfile=KEY)
|
||||
httpsd.socket = ctx.wrap_socket(httpsd.socket, server_side=True)
|
||||
|
||||
print(f"HTTP listening on http://{HOST}:{HTTP_PORT}", flush=True)
|
||||
print(f"HTTPS listening on https://{HOST}:{HTTPS_PORT}", flush=True)
|
||||
print("Use Ctrl+C to stop", flush=True)
|
||||
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
httpsd.serve_forever()
|
||||
+3
-3
@@ -75,9 +75,9 @@ All endpoints return JSON. All mutations use POST/PUT/DELETE. All reads use GET.
|
||||
| GET | `/api/events/soul` | Fetch the agent soul event (kind 31120, d=soul) |
|
||||
| PUT | `/api/events/soul` | Update soul content, republish to relays |
|
||||
| GET | `/api/events/skills` | List all published skills (kind 31123/31124 by own pubkey) |
|
||||
| GET | `/api/events/skills/:slug` | Fetch a single skill by slug |
|
||||
| PUT | `/api/events/skills/:slug` | Update skill content/tags, republish |
|
||||
| DELETE | `/api/events/skills/:slug` | Remove skill from adoption list |
|
||||
| GET | `/api/events/skills/:d_tag` | Fetch a single skill by d_tag |
|
||||
| PUT | `/api/events/skills/:d_tag` | Update skill content/tags, republish |
|
||||
| DELETE | `/api/events/skills/:d_tag` | Remove skill from adoption list |
|
||||
| GET | `/api/events/adoption` | Fetch kind 10123 adoption list |
|
||||
| GET | `/api/events/startup` | List startup events from config |
|
||||
| GET | `/api/events/profile` | Fetch agent kind 0 profile |
|
||||
|
||||
@@ -53,7 +53,7 @@ These are not yet implemented but are on the roadmap:
|
||||
| GET | `/api/events/soul` | Agent soul/system prompt event |
|
||||
| PUT | `/api/events/soul` | Update soul content |
|
||||
| GET | `/api/events/skills` | List skills |
|
||||
| GET/PUT | `/api/events/skills/:slug` | Read/update individual skills |
|
||||
| GET/PUT | `/api/events/skills/:d_tag` | Read/update individual skills |
|
||||
| GET | `/api/events/profile` | Agent Nostr profile |
|
||||
| GET | `/api/tools` | List all tool schemas |
|
||||
| POST | `/api/tools/:name/execute` | Execute a tool directly |
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# Agent Tasks: Short-Term Memory via Context-Injected Task List
|
||||
|
||||
## Summary
|
||||
|
||||
Add a **tasks** system that serves as the agent's short-term working memory. The agent can break down goals into steps, track progress, and see its current task list in every prompt context. Tasks are file-backed (not stored on Nostr) and managed via a dedicated `task_manage` tool.
|
||||
|
||||
## How It Works
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User sends message] --> B[Context builder runs]
|
||||
B --> C[Template resolver hits tasks_content variable]
|
||||
C --> D[Read tasks.json from disk]
|
||||
D --> E{Tasks exist?}
|
||||
E -->|Yes| F[Format tasks as readable text]
|
||||
E -->|No| G[Return empty string - section skipped]
|
||||
F --> H[Inject as system message in prompt]
|
||||
G --> H
|
||||
H --> I[LLM sees current tasks in context]
|
||||
I --> J{LLM decides to update tasks?}
|
||||
J -->|Yes| K[LLM calls task_manage tool]
|
||||
K --> L[Tool updates tasks.json on disk]
|
||||
L --> M[Tool result returned to LLM]
|
||||
J -->|No| N[LLM responds normally]
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
### Storage: `tasks.json`
|
||||
|
||||
A simple JSON file in the agent's working directory. Structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Query admin relay list to find active relays",
|
||||
"status": "done",
|
||||
"created_at": 1709535600,
|
||||
"updated_at": 1709535660
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"text": "Draft long-form article about Nostr relay setup",
|
||||
"status": "active",
|
||||
"created_at": 1709535600,
|
||||
"updated_at": 1709535600
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"text": "Publish article as kind 30023",
|
||||
"status": "pending",
|
||||
"created_at": 1709535600,
|
||||
"updated_at": 1709535600
|
||||
}
|
||||
],
|
||||
"next_id": 4
|
||||
}
|
||||
```
|
||||
|
||||
Task statuses: `pending`, `active`, `done`
|
||||
|
||||
### Tool: `task_manage`
|
||||
|
||||
A single tool with an `action` parameter that covers all operations:
|
||||
|
||||
| Action | Parameters | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `list` | *(none)* | Return all tasks with status |
|
||||
| `add` | `text`, optional `status` | Add a new task, default status `pending` |
|
||||
| `update` | `id`, optional `text`, optional `status` | Update text and/or status of a task |
|
||||
| `remove` | `id` | Remove a task by ID |
|
||||
| `clear` | optional `status` | Remove all tasks, or all with a given status |
|
||||
| `replace` | `tasks` (array of text strings) | Replace entire task list with new items |
|
||||
|
||||
The `replace` action is important — it lets the LLM rewrite the whole plan in one call rather than doing add/remove/update one at a time. This is the most common pattern: the agent works out a plan and writes all steps at once.
|
||||
|
||||
**Tool schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "task_manage",
|
||||
"description": "Manage the agent task list - short-term working memory for tracking steps in a plan. Tasks appear in your context on every message.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["list", "add", "update", "remove", "clear", "replace"]
|
||||
},
|
||||
"text": { "type": "string" },
|
||||
"id": { "type": "integer" },
|
||||
"status": { "type": "string", "enum": ["pending", "active", "done"] },
|
||||
"tasks": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Context Section: `agent_tasks`
|
||||
|
||||
New section in the context template, placed after `adopted_skills` and before `dm_history`:
|
||||
|
||||
```yaml
|
||||
- section: agent_tasks
|
||||
role: system
|
||||
skip_if_empty: true
|
||||
content: |
|
||||
{{tasks_content}}
|
||||
```
|
||||
|
||||
### Template Variable: `{{tasks_content}}`
|
||||
|
||||
New resolver in `agent_template_resolve_var()` that:
|
||||
|
||||
1. Reads `tasks.json` from the working directory
|
||||
2. Parses the JSON
|
||||
3. Formats active/pending tasks as readable text
|
||||
4. Returns empty string if no tasks exist (section gets skipped via `skip_if_empty`)
|
||||
|
||||
**Rendered format in context:**
|
||||
|
||||
```
|
||||
### Current Tasks
|
||||
|
||||
Your active task list - short-term working memory for tracking plan steps.
|
||||
|
||||
- [x] 1. Query admin relay list to find active relays
|
||||
- [-] 2. Draft long-form article about Nostr relay setup
|
||||
- [ ] 3. Publish article as kind 30023
|
||||
```
|
||||
|
||||
Legend: `[x]` = done, `[-]` = active, `[ ]` = pending
|
||||
|
||||
Done tasks are included so the agent has continuity about what it already accomplished, but they could be pruned after a configurable count or age to save tokens.
|
||||
|
||||
### System Prompt Addition
|
||||
|
||||
Add to the agent's behavioral rules in the soul/system prompt:
|
||||
|
||||
```
|
||||
### Task Management
|
||||
- You have a task list that serves as your short-term working memory.
|
||||
- When working on multi-step goals, use task_manage to track your plan.
|
||||
- Update task status as you complete steps.
|
||||
- Your current tasks appear in your context automatically.
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Add `task_manage` tool implementation in `tools.c`
|
||||
|
||||
- New `execute_task_manage()` function
|
||||
- Reads/writes `tasks.json` in the working directory (uses `build_tool_path` for sandboxing)
|
||||
- Handles all 6 actions: list, add, update, remove, clear, replace
|
||||
- Returns JSON result with success/failure and current task list
|
||||
|
||||
### 2. Register `task_manage` tool schema in `tools_build_openai_schema_json()`
|
||||
|
||||
- Add tool definition (t35 or next available) with the schema above
|
||||
|
||||
### 3. Wire `task_manage` into `tools_execute()` dispatch
|
||||
|
||||
- Add `strcmp(tool_name, "task_manage")` branch calling `execute_task_manage()`
|
||||
|
||||
### 4. Add `{{tasks_content}}` template variable resolver in `agent.c`
|
||||
|
||||
- New `build_tasks_content_string()` function
|
||||
- Reads `tasks.json`, formats as markdown checklist
|
||||
- Add to `agent_template_resolve_var()` for var name `tasks_content`
|
||||
|
||||
### 5. Add `agent_tasks` section to context template
|
||||
|
||||
- Add the new section in `context_template.md`
|
||||
- Place after `adopted_skills`, before `dm_history`
|
||||
- Use `skip_if_empty: true` so it costs zero tokens when no tasks exist
|
||||
|
||||
### 6. Add section detection for context logging
|
||||
|
||||
- Add `agent_tasks` detection in `detect_context_section()` in `agent.c`
|
||||
|
||||
### 7. Add task management guidance to system prompt
|
||||
|
||||
- Brief behavioral instruction so the agent knows when/how to use the task list
|
||||
|
||||
## Token Budget Considerations
|
||||
|
||||
- Empty task list: **0 tokens** (skipped via `skip_if_empty`)
|
||||
- Typical 5-task plan: **~80-120 tokens**
|
||||
- Maximum reasonable list of 15 tasks: **~250-350 tokens**
|
||||
- Consider pruning done tasks older than N turns or keeping only the last M done tasks
|
||||
|
||||
## Future: User-Facing To-Do List (Nostr)
|
||||
|
||||
This is explicitly **not** the user-facing to-do list. That future feature would:
|
||||
- Store items as Nostr events (likely a NIP-51 style list or custom kind)
|
||||
- Be visible to the user via Nostr clients
|
||||
- Have its own separate tool (`todo_manage` or similar)
|
||||
- Potentially reference agent tasks that graduate to user-visible items
|
||||
|
||||
The agent tasks system is purely internal working memory.
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/tools.c` | Add `execute_task_manage()`, tool schema, dispatch entry |
|
||||
| `src/agent.c` | Add `build_tasks_content_string()`, resolver entry, section detection |
|
||||
| `context_template.md` | Add `agent_tasks` section |
|
||||
| Soul/system prompt (kind 31120) | Add task management behavioral guidance |
|
||||
@@ -0,0 +1,162 @@
|
||||
# Cheerleader Triggered Skill — Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Create the first triggered skill: whenever the admin posts a kind 1 note on Nostr, the agent reads the note content and sends a DM back to the admin cheering them on, praising them, and telling them how good looking they are.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
The entire triggered-skill infrastructure is **already built and functional**:
|
||||
|
||||
| Component | File | Status |
|
||||
|---|---|---|
|
||||
| Trigger manager (poll, cooldown, add/remove) | `src/trigger_manager.c` | ✅ Complete |
|
||||
| LLM-mediated trigger execution | `src/agent.c` `agent_on_trigger()` | ✅ Complete |
|
||||
| `skill_create` tool with trigger support | `src/tools.c` `execute_skill_create()` | ✅ Complete |
|
||||
| Trigger polling in main loop | `src/main.c` | ✅ Complete |
|
||||
| Config parsing for triggers section | `src/config.c` | ✅ Complete |
|
||||
| `trigger_list` tool | `src/tools.c` | ✅ Complete |
|
||||
|
||||
### Gap Found
|
||||
|
||||
The `skill_create` tool **schema** (what the LLM sees) only exposes 5 parameters: `d_tag`, `content`, `scope`, `description`, `auto_adopt`. The execution function already handles `trigger`, `filter`, `action`, and `enabled` — but these are **not declared in the tool schema**, so the LLM will never pass them.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Fix `skill_create` Tool Schema
|
||||
|
||||
**File:** `src/tools.c` lines ~1607-1641
|
||||
|
||||
Add four new properties to the `skill_create` tool definition so the LLM can see and use them:
|
||||
|
||||
```c
|
||||
// After auto_adopt property (line ~1634):
|
||||
|
||||
cJSON* p_skill_create_trigger = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_create_trigger, "type", "string");
|
||||
cJSON_AddStringToObject(p_skill_create_trigger, "description",
|
||||
"Trigger type. Use nostr-subscription to activate on matching Nostr events");
|
||||
cJSON_AddItemToObject(t22_props, "trigger", p_skill_create_trigger);
|
||||
|
||||
cJSON* p_skill_create_filter = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_create_filter, "type", "string");
|
||||
cJSON_AddStringToObject(p_skill_create_filter, "description",
|
||||
"Nostr subscription filter JSON for the trigger, e.g. {\"kinds\":[1],\"authors\":[\"<hex>\"]}");
|
||||
cJSON_AddItemToObject(t22_props, "filter", p_skill_create_filter);
|
||||
|
||||
cJSON* p_skill_create_action = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_create_action, "type", "string");
|
||||
cJSON_AddStringToObject(p_skill_create_action, "description",
|
||||
"Action type: llm (default, full LLM reasoning) or template (fast interpolation)");
|
||||
cJSON_AddItemToObject(t22_props, "action", p_skill_create_action);
|
||||
|
||||
cJSON* p_skill_create_enabled = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_create_enabled, "type", "boolean");
|
||||
cJSON_AddStringToObject(p_skill_create_enabled, "description",
|
||||
"Whether the trigger is active. Default: true");
|
||||
cJSON_AddItemToObject(t22_props, "enabled", p_skill_create_enabled);
|
||||
```
|
||||
|
||||
This is ~20 lines of code. No changes needed to the execution function — it already handles all four parameters.
|
||||
|
||||
### 2. Add Cheerleader Skill as Startup Event
|
||||
|
||||
**File:** `config.jsonc.example`
|
||||
|
||||
Add a new kind 31123 skill event with trigger tags. Insert before the kind 10123 adoption list event:
|
||||
|
||||
```jsonc
|
||||
// Kind 31123: Public triggered skill — cheerleader
|
||||
// Watches for admin kind 1 notes and sends encouraging DMs.
|
||||
{
|
||||
"kind": 31123,
|
||||
"content": "You are the admin's biggest fan and personal cheerleader. When the admin posts a note on Nostr, read the note content carefully and send them a DM that:\n\n1. References what they actually wrote about\n2. Cheers them on enthusiastically\n3. Praises their intelligence and insight\n4. Tells them how good looking they are\n5. Encourages them to keep posting\n\nBe genuine, warm, and over-the-top positive. Use their name from the admin profile if available. Keep it to 2-3 sentences max.",
|
||||
"tags": [
|
||||
["d", "cheerleader"],
|
||||
["app", "didactyl"],
|
||||
["scope", "public"],
|
||||
["description", "Cheer on the admin whenever they post a kind 1 note"],
|
||||
["trigger", "nostr-subscription"],
|
||||
["filter", "{\"kinds\":[1],\"authors\":[\"ADMIN_PUBKEY_HEX\"]}"],
|
||||
["action", "llm"],
|
||||
["enabled", "true"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** The `ADMIN_PUBKEY_HEX` placeholder in the filter must be replaced with the actual admin pubkey from the config. Since this is a static config example, we use a placeholder. At runtime, the user replaces it with their admin pubkey.
|
||||
|
||||
### 3. Add to Adoption List
|
||||
|
||||
**File:** `config.jsonc.example`
|
||||
|
||||
Add the cheerleader skill address to the kind 10123 adoption list tags:
|
||||
|
||||
```jsonc
|
||||
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:cheerleader"]
|
||||
```
|
||||
|
||||
### 4. Verify Trigger Filter
|
||||
|
||||
The Nostr filter for this trigger:
|
||||
|
||||
```json
|
||||
{"kinds": [1], "authors": ["<admin_pubkey_hex>"]}
|
||||
```
|
||||
|
||||
This matches:
|
||||
- **Kind 1** — text notes only (not DMs, not reactions, not reposts)
|
||||
- **Authors** — only the admin's pubkey (not anyone else's notes)
|
||||
|
||||
The trigger manager polls every 10 seconds (`trigger_manager_poll` checks `now - last_poll_at < 10`), applies the `since` parameter to only fetch events newer than the last seen, and enforces the configured cooldown (default 60 seconds) between firings.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin as Admin Client
|
||||
participant Relay as Nostr Relay
|
||||
participant TM as Trigger Manager
|
||||
participant Agent as agent_on_trigger
|
||||
participant LLM as LLM API
|
||||
|
||||
Admin->>Relay: Publish kind 1 note
|
||||
Note over TM: Poll every 10s
|
||||
TM->>Relay: Query filter: kinds=1, authors=admin
|
||||
Relay-->>TM: New event found
|
||||
TM->>TM: Check cooldown, check last_seen_created_at
|
||||
TM->>Agent: agent_on_trigger with skill content + event
|
||||
Agent->>Agent: Build system prompt with soul + skill instructions
|
||||
Agent->>LLM: llm_chat with system + triggering event JSON
|
||||
LLM-->>Agent: Cheerful response
|
||||
Agent->>Relay: DM to admin via nostr_handler_send_dm_auto
|
||||
Relay-->>Admin: Encrypted DM with encouragement
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
| File | Change | Lines |
|
||||
|---|---|---|
|
||||
| `src/tools.c` | Add trigger/filter/action/enabled to skill_create schema | ~20 lines added |
|
||||
| `config.jsonc.example` | Add cheerleader skill startup event | ~20 lines added |
|
||||
| `config.jsonc.example` | Add cheerleader to adoption list | 1 line added |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
1. Build with `make`
|
||||
2. Start the agent
|
||||
3. Verify trigger loads on startup via `trigger_list` tool or HTTP API `/status`
|
||||
4. Post a kind 1 note from the admin account
|
||||
5. Wait up to ~10 seconds for the poll cycle
|
||||
6. Receive a cheerful DM from the agent
|
||||
7. Verify cooldown works — posting again within 60s should not trigger a second DM
|
||||
@@ -109,7 +109,7 @@ The agent selects the profile matching the active model, falling back to `defaul
|
||||
│ - skill_remove (remove) │
|
||||
│ │
|
||||
│ Structure per skill: │
|
||||
│ - slug (string) │
|
||||
│ - d_tag (string) │
|
||||
│ - description (string) │
|
||||
│ - content (string, full) │
|
||||
│ - scope (public/private) │
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# Context Optimization Plan
|
||||
|
||||
Analysis of [`context.log.md`](../context.log.md) (13,609 bytes / ~3,402 tokens across 20 sections) and [`context_template.md`](../context_template.md).
|
||||
|
||||
## Issues Found
|
||||
|
||||
### 1. Massive Duplication in `startup_events` Section
|
||||
|
||||
The **startup_events** section (line 74-80 in the log) dumps the *entire* `config.startup_events` array as raw JSON — including the full soul/system prompt (kind 31120) which is already sent verbatim as the **system_prompt** section. The soul text appears **twice** in every request.
|
||||
|
||||
**Estimated waste:** ~1,500-2,000 tokens per request.
|
||||
|
||||
**Fix:** Filter out kind 31120 (soul) from the startup_events JSON blob, or better yet, only include kinds the model actually needs to reference (kind 0 profile, kind 10002 relay list, kind 3 contacts). The soul is already the system prompt — repeating it as data is pure waste.
|
||||
|
||||
### 2. Duplicate Startup Messages in DM History
|
||||
|
||||
The DM history contains **four separate** `Didactyl has started up and is online (version v0.0.29, connected relays: 4/4).` assistant messages (lines 123-127, 130-134, 144-148, 222-226, 245-249). These are startup announcement DMs that got stored as separate events. The model sees the same boilerplate startup message repeated across the conversation.
|
||||
|
||||
**Estimated waste:** ~200-300 tokens.
|
||||
|
||||
**Fix:** Deduplicate consecutive identical assistant messages in the DM history builder, or filter out startup announcement messages (they carry no conversational value).
|
||||
|
||||
### 3. Skills Rendered as Raw JSON Instead of Structured Text
|
||||
|
||||
Skill instructions at lines 97-118 are dumped as raw JSON objects. Models parse structured natural language far more reliably than nested JSON. The `content_fields` serialization format wastes tokens on JSON syntax characters and key quoting.
|
||||
|
||||
**Estimated waste:** ~100-200 tokens of JSON overhead per skill, plus reduced comprehension quality.
|
||||
|
||||
**Fix:** When serializing `content_fields`-based skills for context, flatten them into readable text:
|
||||
```
|
||||
Skill: long_form_note
|
||||
Description: How to publish a NIP-23 long-form article (kind 30023)
|
||||
NIP: NIP-23
|
||||
Event Kind: 30023
|
||||
Format: The content field must be markdown text...
|
||||
Required Tags:
|
||||
- d: Addressable identifier d_tag...
|
||||
- title: Human-readable article title
|
||||
- published_at: Unix timestamp as string...
|
||||
Procedure:
|
||||
1. Determine title and d tag...
|
||||
2. Draft markdown body content...
|
||||
```
|
||||
|
||||
### 4. Empty Sections Still Sent
|
||||
|
||||
The **admin_relay_list** section (line 65-71) has no data — the JSON value is empty. Sending an empty section wastes tokens on the header/framing with no informational value.
|
||||
|
||||
**Estimated waste:** ~30-40 tokens.
|
||||
|
||||
**Fix:** Skip sections where the resolved variable is empty or whitespace-only.
|
||||
|
||||
### 5. Admin Identity Could Be Merged with Admin Profile
|
||||
|
||||
The **admin_identity** section (line 47-53) sends just the hex pubkey, then **admin_profile** (line 56-62) sends the full kind 0 JSON which implicitly identifies the admin. These could be a single section.
|
||||
|
||||
**Estimated savings:** ~40-50 tokens of framing overhead.
|
||||
|
||||
### 6. `admin_notes` Placement Breaks Conversation Flow
|
||||
|
||||
In the template, `admin_notes` is placed *after* `dm_history` (expand). In the actual log, this means a system message appears sandwiched between DM history messages (line 252, between assistant messages and the final user message at line 274). This breaks the natural conversation flow and may confuse the model about message ordering.
|
||||
|
||||
**Fix:** Move `admin_notes` *before* `dm_history` in the template so all system context is grouped together before the conversation begins.
|
||||
|
||||
### 7. No Agent Self-Identity Section
|
||||
|
||||
The model knows it is Didactyl from the system prompt, but there is no section telling it its own pubkey/npub. The admin pubkey is provided but the agent's own key is not in the context (it is only available via the `nostr_pubkey` tool). Adding a small self-identity section would let the model reference its own key without a tool call.
|
||||
|
||||
**Estimated cost:** ~20-30 tokens.
|
||||
|
||||
## Priority Summary
|
||||
|
||||
| Priority | Issue | Token Savings | Complexity |
|
||||
|----------|-------|---------------|------------|
|
||||
| **P0** | Soul duplicated in startup_events | ~1,500-2,000 | Low — filter kind 31120 from startup blob |
|
||||
| **P1** | Duplicate startup DMs in history | ~200-300 | Medium — dedup logic in history builder |
|
||||
| **P1** | Skills as raw JSON | ~100-200 + quality | Medium — flatten content_fields to text |
|
||||
| **P2** | Empty sections still sent | ~30-40 | Low — skip empty resolved vars |
|
||||
| **P2** | admin_notes after dm_history | 0 (quality) | Low — reorder template |
|
||||
| **P3** | Merge admin_identity + admin_profile | ~40-50 | Low — template change |
|
||||
| **P3** | Add agent self-identity section | -20-30 (adds) | Low — new template var |
|
||||
|
||||
## Bug: Kind 10002 Relay List Is Always Empty
|
||||
|
||||
At [`nostr_handler.c:705`](../src/nostr_handler.c:705) the kind 10002 handler stores `content->valuestring`, but NIP-65 relay list events have an **empty content field** — the relay URLs live in the **tags** as `["r", "wss://relay.example.com"]` entries. So `g_admin_kind10002_json` is always `""`.
|
||||
|
||||
**Fix:** Parse the `"r"` tags from the kind 10002 event and serialize them as a JSON array of relay URL strings (or plain-text list).
|
||||
|
||||
## Sender Verification Status
|
||||
|
||||
The [`tier`](../src/nostr_handler.h:8) enum (`DIDACTYL_SENDER_ADMIN`, `DIDACTYL_SENDER_WOT`, `DIDACTYL_SENDER_STRANGER`) is already resolved before [`agent_on_message()`](../src/agent.c:1453) is called, but it is **not passed into the context builder**. The model has no way to know whether the current message was cryptographically verified as coming from the administrator vs. a web-of-trust contact.
|
||||
|
||||
**Fix:** Pass the sender tier into the context builder and expose it as a template variable (e.g. `{{sender_verification}}`) that resolves to text like:
|
||||
- `"This message has been cryptographically verified as coming from your administrator."`
|
||||
- `"This message is from a web-of-trust contact (not the administrator)."`
|
||||
|
||||
## Proposed Optimized Template
|
||||
|
||||
```yaml
|
||||
- section: agent_identity
|
||||
role: system
|
||||
content: |
|
||||
Agent Identity
|
||||
Your pubkey (hex): {{agent_pubkey}}
|
||||
|
||||
- section: sender_context
|
||||
role: system
|
||||
content: |
|
||||
{{sender_verification}}
|
||||
|
||||
- section: admin_context
|
||||
role: system
|
||||
content: |
|
||||
Administrator Context
|
||||
|
||||
Pubkey (hex): {{admin_pubkey}}
|
||||
{{admin_profile_plain}}
|
||||
{{admin_relay_list_plain}}
|
||||
|
||||
- section: startup_events
|
||||
role: system
|
||||
skip_if_empty: true
|
||||
content: |
|
||||
Startup Events Memory
|
||||
{{startup_events_json}}
|
||||
|
||||
- section: adopted_skills
|
||||
role: system
|
||||
skip_if_empty: true
|
||||
content: |
|
||||
{{adopted_skills_content}}
|
||||
|
||||
- section: admin_notes
|
||||
role: system
|
||||
skip_if_empty: true
|
||||
content: |
|
||||
Administrator Recent Notes (source: nostr kind 1)
|
||||
{{admin_notes_content}}
|
||||
|
||||
- section: dm_history
|
||||
role: expand
|
||||
limit: 12
|
||||
```
|
||||
|
||||
Key changes from current template:
|
||||
- **No markdown headers** in system sections — plain English throughout
|
||||
- **Merged admin section** combines identity, profile, and relay list
|
||||
- **`{{admin_profile_plain}}`** — new variable that renders kind 0 JSON as readable text (e.g. `Name: WSB, About: ...`)
|
||||
- **`{{admin_relay_list_plain}}`** — new variable that renders relay URLs from tags as a plain list
|
||||
- **`{{sender_verification}}`** — new variable stating cryptographic verification status
|
||||
- **`admin_notes` moved before `dm_history`** so all system context is grouped before conversation
|
||||
- **`skip_if_empty`** prevents sending empty sections
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Fix kind 10002 relay list bug** — extract relay URLs from tags instead of content in [`nostr_handler.c:705`](../src/nostr_handler.c:705)
|
||||
2. **Filter kind 31120** from `startup_events_json` variable resolver in [`agent.c`](../src/agent.c)
|
||||
3. **Deduplicate consecutive identical messages** in DM history builder
|
||||
4. **Flatten `content_fields` JSON skills** into readable text format
|
||||
5. **Add `skip_if_empty` support** to template engine (skip section when resolved content is blank)
|
||||
6. **Reorder template** — move `admin_notes` before `dm_history`
|
||||
7. **Add `agent_pubkey` template variable** and agent identity section
|
||||
8. **Merge admin sections** — combine identity + profile (plain English) + relay list into one section
|
||||
9. **Add `admin_profile_plain` variable** — parse kind 0 JSON into readable text
|
||||
10. **Add `admin_relay_list_plain` variable** — parse kind 10002 tags into relay URL list
|
||||
11. **Pass sender tier to context builder** and add `sender_verification` template variable
|
||||
12. **Remove markdown formatting** from system section content — use plain English
|
||||
@@ -57,7 +57,7 @@ flowchart TD
|
||||
LOOP --> LLM
|
||||
LLM -->|tool_call| TOOLS
|
||||
TOOLS -->|nostr tools| NOSTR
|
||||
TOOLS -->|shell_exec| SHELL
|
||||
TOOLS -->|local_shell_exec| SHELL
|
||||
TOOLS -->|result| LOOP
|
||||
LLM -->|final answer| DMS
|
||||
```
|
||||
@@ -212,7 +212,7 @@ sequenceDiagram
|
||||
|
||||
| Tool | Description | Notes |
|
||||
|---|---|---|
|
||||
| `shell_exec` | Run a shell command, capture stdout/stderr | Sandboxed with timeouts |
|
||||
| `local_shell_exec` | Run a shell command, capture stdout/stderr | Sandboxed with timeouts |
|
||||
|
||||
---
|
||||
|
||||
@@ -377,7 +377,7 @@ void agent_on_message(const char* sender, const char* message) {
|
||||
3. **Agent loop** — rewrite `agent.c` with the tool-call loop
|
||||
4. **`nostr_post` tool** — publish any kind event
|
||||
5. **`nostr_query` tool** — query relays with filters
|
||||
6. **`shell_exec` tool** — sandboxed shell command execution
|
||||
6. **`local_shell_exec` tool** — sandboxed shell command execution
|
||||
7. **`nostr_dm` tool** — NIP-17 private DMs
|
||||
8. **Config extension** — parse tools config section
|
||||
9. **Security hardening** — timeouts, output limits, allowlists
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
| 5 | `nostr_react` | 7 / NIP-25 | ✅ Shipped |
|
||||
| 6 | `nostr_profile_get` | 0 query | ✅ Shipped |
|
||||
| 7 | `nostr_relay_status` | Pool stats | ✅ Shipped |
|
||||
| 8 | `shell_exec` | OS | ✅ Shipped |
|
||||
| 9 | `file_read` | OS | ✅ Shipped |
|
||||
| 10 | `file_write` | OS | ✅ Shipped |
|
||||
| 8 | `local_shell_exec` | OS | ✅ Shipped |
|
||||
| 9 | `local_file_read` | OS | ✅ Shipped |
|
||||
| 10 | `local_file_write` | OS | ✅ Shipped |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
# Plan: Implement Webhook, Cron, and Chain Trigger Types
|
||||
|
||||
## Overview
|
||||
|
||||
Add three new trigger types to Didactyl's trigger system alongside the existing `nostr-subscription` type. Implementation order: webhook → cron → chain, followed by documentation updates.
|
||||
|
||||
## Current Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Current Flow
|
||||
NS[Nostr Event] --> SUB[Subscription Callback]
|
||||
SUB --> MF[maybe_fire_trigger_locked]
|
||||
MF --> EA{Action Type?}
|
||||
EA -->|template| TA[execute_template_action]
|
||||
EA -->|llm| LA[execute_llm_action]
|
||||
LA --> AOT[agent_on_trigger]
|
||||
end
|
||||
```
|
||||
|
||||
All triggers are implicitly `nostr-subscription` — there is no type discriminator field in `active_trigger_t`.
|
||||
|
||||
## Target Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Trigger Sources
|
||||
NS[Nostr Subscription]
|
||||
WH[Webhook HTTP POST]
|
||||
CR[Cron Timer]
|
||||
CH[Chain - Post-Execution]
|
||||
end
|
||||
|
||||
subgraph Trigger Manager
|
||||
NS --> MF[maybe_fire_trigger]
|
||||
WH --> MF
|
||||
CR --> MF
|
||||
CH --> MF
|
||||
end
|
||||
|
||||
subgraph Dispatch
|
||||
MF --> EA{Action Type?}
|
||||
EA -->|template| TA[execute_template_action]
|
||||
EA -->|llm| LA[execute_llm_action]
|
||||
LA --> AOT[agent_on_trigger]
|
||||
AOT -->|on completion| CHK[Check chain triggers]
|
||||
CHK -->|match| CH
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation — Type System and API Changes
|
||||
|
||||
### 1.1 Add trigger type enum to `trigger_manager.h`
|
||||
|
||||
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:15)
|
||||
|
||||
Add a new enum after the existing `trigger_action_type_t` at line 18:
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
TRIGGER_TYPE_NOSTR_SUBSCRIPTION = 0,
|
||||
TRIGGER_TYPE_WEBHOOK,
|
||||
TRIGGER_TYPE_CRON,
|
||||
TRIGGER_TYPE_CHAIN
|
||||
} trigger_type_t;
|
||||
```
|
||||
|
||||
### 1.2 Extend `active_trigger_t` struct
|
||||
|
||||
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:20)
|
||||
|
||||
Add three new fields to the struct after [`enabled`](src/trigger_manager.h:25):
|
||||
|
||||
```c
|
||||
trigger_type_t trigger_type; // discriminator: which trigger source
|
||||
time_t last_cron_fire; // cron: last time this trigger fired
|
||||
char cron_expr[64]; // cron: parsed 5-field cron expression
|
||||
```
|
||||
|
||||
The struct currently has `subscription` and `subscription_ctx` fields which are only relevant for `nostr-subscription` — they'll remain but be NULL for other types.
|
||||
|
||||
### 1.3 Add trigger type string conversion helpers
|
||||
|
||||
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:57) — add declarations:
|
||||
|
||||
```c
|
||||
trigger_type_t trigger_type_from_string(const char *s);
|
||||
const char* trigger_type_to_string(trigger_type_t t);
|
||||
```
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:20) — add implementations after `clamp_enabled()`:
|
||||
|
||||
```c
|
||||
trigger_type_t trigger_type_from_string(const char *s) {
|
||||
if (!s) return TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
|
||||
if (strcmp(s, "webhook") == 0) return TRIGGER_TYPE_WEBHOOK;
|
||||
if (strcmp(s, "cron") == 0) return TRIGGER_TYPE_CRON;
|
||||
if (strcmp(s, "chain") == 0) return TRIGGER_TYPE_CHAIN;
|
||||
return TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
|
||||
}
|
||||
|
||||
const char* trigger_type_to_string(trigger_type_t t) {
|
||||
switch (t) {
|
||||
case TRIGGER_TYPE_WEBHOOK: return "webhook";
|
||||
case TRIGGER_TYPE_CRON: return "cron";
|
||||
case TRIGGER_TYPE_CHAIN: return "chain";
|
||||
default: return "nostr-subscription";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 Update `trigger_manager_add()` signature
|
||||
|
||||
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:44) and [`src/trigger_manager.c`](src/trigger_manager.c:569)
|
||||
|
||||
Add `const char* trigger_type_str` parameter:
|
||||
|
||||
```c
|
||||
int trigger_manager_add(trigger_manager_t* mgr,
|
||||
const char* skill_d_tag,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
const char* trigger_type_str, // NEW
|
||||
int enabled);
|
||||
```
|
||||
|
||||
Inside the function body at [line 604](src/trigger_manager.c:604), after `memset(t, 0, sizeof(*t))` and field assignments:
|
||||
|
||||
```c
|
||||
t->trigger_type = trigger_type_from_string(trigger_type_str);
|
||||
|
||||
// For cron triggers, copy the filter as the cron expression
|
||||
if (t->trigger_type == TRIGGER_TYPE_CRON) {
|
||||
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
|
||||
t->last_cron_fire = 0;
|
||||
}
|
||||
|
||||
// Only create Nostr subscription for nostr-subscription type
|
||||
if (t->trigger_type == TRIGGER_TYPE_NOSTR_SUBSCRIPTION) {
|
||||
if (register_trigger_subscription_locked(mgr, t) != 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
DEBUG_WARN("[didactyl] trigger add rejected: subscription failed d_tag=%s", skill_d_tag);
|
||||
memset(t, 0, sizeof(*t));
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.5 Update `trigger_manager_update()` signature
|
||||
|
||||
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:51) and [`src/trigger_manager.c`](src/trigger_manager.c:661)
|
||||
|
||||
Same pattern — add `const char* trigger_type_str` parameter. Inside the body at [line 683](src/trigger_manager.c:683):
|
||||
|
||||
```c
|
||||
t->trigger_type = trigger_type_from_string(trigger_type_str);
|
||||
|
||||
if (t->trigger_type == TRIGGER_TYPE_CRON) {
|
||||
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
|
||||
}
|
||||
|
||||
// Only (re)subscribe for nostr-subscription type
|
||||
if (t->trigger_type == TRIGGER_TYPE_NOSTR_SUBSCRIPTION) {
|
||||
if (register_trigger_subscription_locked(mgr, t) != 0) { ... }
|
||||
} else {
|
||||
// Close any existing subscription if type changed
|
||||
close_trigger_subscription_locked(t);
|
||||
}
|
||||
```
|
||||
|
||||
### 1.6 Update all call sites of `trigger_manager_add()` and `trigger_manager_update()`
|
||||
|
||||
There are 5 call sites that need the new `trigger_type_str` parameter:
|
||||
|
||||
| Call Site | File | Line | Current `trigger_type_str` value |
|
||||
|---|---|---|---|
|
||||
| `trigger_manager_load_from_skills()` | [`src/trigger_manager.c`](src/trigger_manager.c:485) | 485 | `trigger_s` — already extracted from tags |
|
||||
| `trigger_manager_load_from_startup_events()` | [`src/trigger_manager.c`](src/trigger_manager.c:555) | 555 | `trigger_s` — already extracted from tags |
|
||||
| `execute_skill_create()` | [`src/tools.c`](src/tools.c:3678) | 3678 | `trigger_str` — already available |
|
||||
| `execute_skill_edit()` update call | [`src/tools.c`](src/tools.c:4213) | 4213 | `merged_trigger` — already available |
|
||||
| `execute_skill_edit()` add fallback | [`src/tools.c`](src/tools.c:4219) | 4219 | `merged_trigger` — already available |
|
||||
| `trigger_manager_add()` → `trigger_manager_update()` cross-call | [`src/trigger_manager.c`](src/trigger_manager.c:588) | 588 | Pass through from caller |
|
||||
| `trigger_manager_update()` → `trigger_manager_add()` cross-call | [`src/trigger_manager.c`](src/trigger_manager.c:680) | 680 | Pass through from caller |
|
||||
|
||||
### 1.7 Update loading functions to accept all trigger types
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:482)
|
||||
|
||||
In `trigger_manager_load_from_skills()`, change the filter at line 482 from:
|
||||
```c
|
||||
if (trigger_s && strcmp(trigger_s, "nostr-subscription") == 0 && filter_s && filter_s[0] != '\0') {
|
||||
```
|
||||
To:
|
||||
```c
|
||||
if (trigger_s && filter_s && filter_s[0] != '\0' &&
|
||||
(strcmp(trigger_s, "nostr-subscription") == 0 ||
|
||||
strcmp(trigger_s, "webhook") == 0 ||
|
||||
strcmp(trigger_s, "cron") == 0 ||
|
||||
strcmp(trigger_s, "chain") == 0)) {
|
||||
```
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:547)
|
||||
|
||||
Same change in `trigger_manager_load_from_startup_events()` at line 547.
|
||||
|
||||
Note: For `webhook` type, `filter` can be empty/unused since webhooks are triggered by HTTP POST, not by a filter match. Consider allowing empty filter for webhook type. However, keeping the existing requirement that filter must be non-empty is simpler — webhook skills can use `filter: "{}"` as a placeholder.
|
||||
|
||||
### 1.8 Update `trigger_manager_status_json()`
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:761)
|
||||
|
||||
After the existing `cJSON_AddStringToObject(item, "filter_json", ...)` at line 762, add:
|
||||
|
||||
```c
|
||||
cJSON_AddStringToObject(item, "type", trigger_type_to_string(t->trigger_type));
|
||||
```
|
||||
|
||||
### 1.9 Update `tools.c` validation
|
||||
|
||||
**File:** [`src/tools.c`](src/tools.c:3603)
|
||||
|
||||
In `execute_skill_create()`, change line 3603 from:
|
||||
```c
|
||||
if (trigger_str && strcmp(trigger_str, "nostr-subscription") != 0) {
|
||||
```
|
||||
To:
|
||||
```c
|
||||
if (trigger_str &&
|
||||
strcmp(trigger_str, "nostr-subscription") != 0 &&
|
||||
strcmp(trigger_str, "webhook") != 0 &&
|
||||
strcmp(trigger_str, "cron") != 0 &&
|
||||
strcmp(trigger_str, "chain") != 0) {
|
||||
```
|
||||
|
||||
Update the error message to list valid types.
|
||||
|
||||
**File:** [`src/tools.c`](src/tools.c:4177)
|
||||
|
||||
In `execute_skill_edit()`, change line 4177 from:
|
||||
```c
|
||||
if (strcmp(merged_trigger, "nostr-subscription") != 0) {
|
||||
```
|
||||
To the same multi-type check.
|
||||
|
||||
Also update the `trigger_manager_add()` and `trigger_manager_update()` calls at [lines 3678](src/tools.c:3678) and [4213-4224](src/tools.c:4213) to pass the trigger type string.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Webhook Trigger
|
||||
|
||||
### 2.1 Add `POST /api/trigger/:d_tag` route handler
|
||||
|
||||
**File:** [`src/http_api.c`](src/http_api.c:1154)
|
||||
|
||||
Add a new handler function before `http_handler()`:
|
||||
|
||||
```c
|
||||
static void handle_trigger_webhook(struct mg_connection* c, struct mg_http_message* hm, const char* d_tag) {
|
||||
if (!g_api_ctx.trigger_manager) {
|
||||
reply_error(c, 503, "trigger manager unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the trigger by d_tag
|
||||
// Need a new function: trigger_manager_find_by_d_tag() that returns a copy
|
||||
active_trigger_t trigger_copy;
|
||||
if (trigger_manager_find(g_api_ctx.trigger_manager, d_tag, &trigger_copy) != 0) {
|
||||
reply_error(c, 404, "no trigger found for d_tag");
|
||||
return;
|
||||
}
|
||||
|
||||
if (trigger_copy.trigger_type != TRIGGER_TYPE_WEBHOOK) {
|
||||
reply_error(c, 400, "trigger is not a webhook type");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trigger_copy.enabled) {
|
||||
reply_error(c, 400, "trigger is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse optional JSON body as the webhook payload
|
||||
cJSON* payload = parse_body_json(hm);
|
||||
if (!payload) {
|
||||
reply_error(c, 400, "invalid JSON body");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build synthetic triggering event
|
||||
cJSON* event = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(event, "type", "webhook");
|
||||
cJSON_AddStringToObject(event, "d_tag", d_tag);
|
||||
cJSON_AddNumberToObject(event, "created_at", (double)time(NULL));
|
||||
cJSON_AddItemToObject(event, "payload", payload);
|
||||
|
||||
// Fire the trigger - dispatch based on action type
|
||||
if (trigger_copy.action_type == TRIGGER_ACTION_LLM) {
|
||||
agent_on_trigger(trigger_copy.skill_d_tag,
|
||||
trigger_copy.skill_content,
|
||||
event,
|
||||
"webhook");
|
||||
}
|
||||
// Template actions could also be supported here
|
||||
|
||||
cJSON_Delete(event);
|
||||
|
||||
// Return success immediately
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddStringToObject(root, "d_tag", d_tag);
|
||||
cJSON_AddStringToObject(root, "status", "fired");
|
||||
reply_json(c, 200, root);
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Add route to `http_handler()`
|
||||
|
||||
**File:** [`src/http_api.c`](src/http_api.c:1201)
|
||||
|
||||
Before the 404 fallthrough at line 1206, add:
|
||||
|
||||
```c
|
||||
if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/trigger/*"), NULL)) {
|
||||
// Extract d_tag from URI: /api/trigger/{d_tag}
|
||||
struct mg_str uri = hm->uri;
|
||||
const char* prefix = "/api/trigger/";
|
||||
size_t prefix_len = strlen(prefix);
|
||||
if (uri.len > prefix_len) {
|
||||
char d_tag[TRIGGER_SKILL_D_TAG_MAX];
|
||||
size_t tag_len = uri.len - prefix_len;
|
||||
if (tag_len >= sizeof(d_tag)) tag_len = sizeof(d_tag) - 1;
|
||||
memcpy(d_tag, uri.buf + prefix_len, tag_len);
|
||||
d_tag[tag_len] = '\0';
|
||||
handle_trigger_webhook(c, hm, d_tag);
|
||||
return;
|
||||
}
|
||||
reply_error(c, 400, "missing d_tag in trigger URL");
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Add `trigger_manager_find()` function
|
||||
|
||||
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:50) and [`src/trigger_manager.c`](src/trigger_manager.c:631)
|
||||
|
||||
New function to look up a trigger by d_tag and return a copy:
|
||||
|
||||
```c
|
||||
// Declaration
|
||||
int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out);
|
||||
|
||||
// Implementation
|
||||
int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out) {
|
||||
if (!mgr || !skill_d_tag || !out) return -1;
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
int idx = find_trigger_index_locked(mgr, skill_d_tag);
|
||||
if (idx < 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return -1;
|
||||
}
|
||||
*out = mgr->triggers[idx];
|
||||
// Clear pointer fields in copy to prevent double-free
|
||||
out->subscription = NULL;
|
||||
out->subscription_ctx = NULL;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Webhook cooldown
|
||||
|
||||
The webhook handler should respect the same cooldown as other triggers. Either:
|
||||
- Call `maybe_fire_trigger_locked()` from the webhook handler (requires refactoring to expose it), or
|
||||
- Add cooldown checking in the webhook handler using `trigger_copy.last_fired` and updating it via a new `trigger_manager_mark_fired()` function
|
||||
|
||||
Recommended: Add a `trigger_manager_fire()` function that encapsulates the cooldown check and dispatch, usable by both the Nostr subscription callback and the webhook handler. This avoids duplicating cooldown logic.
|
||||
|
||||
```c
|
||||
int trigger_manager_fire(trigger_manager_t* mgr, const char* skill_d_tag, cJSON* event, const char* source);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Cron Trigger
|
||||
|
||||
### 3.1 Cron expression parser
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c)
|
||||
|
||||
Add a minimal 5-field cron expression matcher. The cron expression format is: `minute hour day-of-month month day-of-week`
|
||||
|
||||
```c
|
||||
// Returns 1 if the cron expression matches the given time, 0 otherwise
|
||||
static int cron_matches(const char* expr, const struct tm* tm);
|
||||
|
||||
// Helper: check if a single field matches a value
|
||||
// Supports: *, specific number, comma-separated list, ranges with -, step with /
|
||||
static int cron_field_matches(const char* field, int value, int min, int max);
|
||||
```
|
||||
|
||||
The parser needs to handle:
|
||||
- `*` — match any
|
||||
- `5` — match exact value
|
||||
- `1,15` — match list
|
||||
- `1-5` — match range
|
||||
- `*/15` — match step
|
||||
- `1-5/2` — match range with step
|
||||
|
||||
This is ~80-100 lines of C. Keep it simple — no named days/months, no special strings like `@hourly`.
|
||||
|
||||
### 3.2 Implement `trigger_manager_poll()` for cron
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:718)
|
||||
|
||||
Replace the no-op `trigger_manager_poll()` with cron checking logic:
|
||||
|
||||
```c
|
||||
int trigger_manager_poll(trigger_manager_t* mgr) {
|
||||
if (!mgr) return 0;
|
||||
|
||||
time_t now = time(NULL);
|
||||
|
||||
// Only check once per minute (cron resolution is 1 minute)
|
||||
if (now - mgr->last_poll_at < 60) return 0;
|
||||
mgr->last_poll_at = now;
|
||||
|
||||
struct tm tm_now;
|
||||
localtime_r(&now, &tm_now);
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
active_trigger_t* t = &mgr->triggers[i];
|
||||
if (!t->enabled || t->trigger_type != TRIGGER_TYPE_CRON) continue;
|
||||
if (t->cron_expr[0] == '\0') continue;
|
||||
|
||||
if (!cron_matches(t->cron_expr, &tm_now)) continue;
|
||||
|
||||
// Prevent double-fire within same minute
|
||||
struct tm tm_last;
|
||||
localtime_r(&t->last_cron_fire, &tm_last);
|
||||
if (t->last_cron_fire > 0 &&
|
||||
tm_last.tm_min == tm_now.tm_min &&
|
||||
tm_last.tm_hour == tm_now.tm_hour &&
|
||||
tm_last.tm_mday == tm_now.tm_mday) {
|
||||
continue;
|
||||
}
|
||||
|
||||
t->last_cron_fire = now;
|
||||
t->last_fired = now;
|
||||
|
||||
// Copy trigger data before unlocking
|
||||
active_trigger_t trigger_copy = *t;
|
||||
trigger_copy.subscription = NULL;
|
||||
trigger_copy.subscription_ctx = NULL;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
// Build synthetic cron event
|
||||
cJSON* event = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(event, "type", "cron");
|
||||
cJSON_AddStringToObject(event, "d_tag", trigger_copy.skill_d_tag);
|
||||
cJSON_AddNumberToObject(event, "created_at", (double)now);
|
||||
cJSON_AddStringToObject(event, "cron_expr", trigger_copy.cron_expr);
|
||||
|
||||
if (trigger_copy.action_type == TRIGGER_ACTION_TEMPLATE) {
|
||||
execute_template_action(mgr, &trigger_copy, event, "cron");
|
||||
} else {
|
||||
execute_llm_action(&trigger_copy, event, "cron");
|
||||
}
|
||||
|
||||
cJSON_Delete(event);
|
||||
|
||||
// Re-lock and continue scanning
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Cron expression in skill tags
|
||||
|
||||
For cron triggers, the `filter` tag contains the cron expression instead of a JSON filter:
|
||||
- `["trigger", "cron"]`
|
||||
- `["filter", "0 * * * *"]` — fires every hour at minute 0
|
||||
- `["filter", "*/5 * * * *"]` — fires every 5 minutes
|
||||
|
||||
This reuses the existing `filter` tag semantics — the filter meaning depends on the trigger type.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Chain Trigger
|
||||
|
||||
### 4.1 Post-execution hook in `agent_on_trigger()`
|
||||
|
||||
**File:** [`src/agent.c`](src/agent.c:1913)
|
||||
|
||||
After the tool loop completes at [line 2041](src/agent.c:2041), before cleanup, add a chain trigger check:
|
||||
|
||||
```c
|
||||
// After the tool loop, check for chain triggers
|
||||
// Need access to trigger_manager — use agent_get_trigger_manager() or global
|
||||
trigger_manager_fire_chains(g_trigger_manager, skill_d_tag, messages);
|
||||
```
|
||||
|
||||
This requires:
|
||||
1. The agent needs access to the trigger manager — it already has this via [`agent_set_trigger_manager()`](src/agent.h:12)
|
||||
2. A new function `trigger_manager_fire_chains()` that scans for chain triggers whose filter matches the completed skill's d_tag
|
||||
|
||||
### 4.2 Add `trigger_manager_fire_chains()` function
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c)
|
||||
|
||||
```c
|
||||
void trigger_manager_fire_chains(trigger_manager_t* mgr,
|
||||
const char* source_d_tag,
|
||||
cJSON* source_output) {
|
||||
if (!mgr || !source_d_tag) return;
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
active_trigger_t* t = &mgr->triggers[i];
|
||||
if (!t->enabled || t->trigger_type != TRIGGER_TYPE_CHAIN) continue;
|
||||
|
||||
// For chain triggers, filter_json contains the source skill d_tag
|
||||
if (strcmp(t->filter_json, source_d_tag) != 0) continue;
|
||||
|
||||
// Cooldown check
|
||||
time_t now = time(NULL);
|
||||
int cooldown = mgr->cfg->triggers.cooldown_seconds;
|
||||
if (cooldown > 0 && t->last_fired > 0 && (now - t->last_fired) < cooldown) continue;
|
||||
|
||||
t->last_fired = now;
|
||||
|
||||
active_trigger_t trigger_copy = *t;
|
||||
trigger_copy.subscription = NULL;
|
||||
trigger_copy.subscription_ctx = NULL;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
// Build synthetic chain event
|
||||
cJSON* event = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(event, "type", "chain");
|
||||
cJSON_AddStringToObject(event, "source_d_tag", source_d_tag);
|
||||
cJSON_AddNumberToObject(event, "created_at", (double)now);
|
||||
if (source_output) {
|
||||
cJSON* output_copy = cJSON_Duplicate(source_output, 1);
|
||||
if (output_copy) {
|
||||
cJSON_AddItemToObject(event, "source_output", output_copy);
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger_copy.action_type == TRIGGER_ACTION_LLM) {
|
||||
execute_llm_action(&trigger_copy, event, "chain");
|
||||
}
|
||||
// Note: template actions could also be supported
|
||||
|
||||
cJSON_Delete(event);
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Chain trigger recursion protection
|
||||
|
||||
To prevent infinite chain loops (A → B → A → B → ...), add a chain depth counter:
|
||||
|
||||
- Add a `static __thread int chain_depth = 0;` in `trigger_manager_fire_chains()`
|
||||
- Increment before firing, decrement after
|
||||
- Refuse to fire if depth exceeds a limit (e.g., 5)
|
||||
|
||||
### 4.4 Chain trigger skill tags
|
||||
|
||||
For chain triggers, the `filter` tag contains the source skill's d_tag:
|
||||
- `["trigger", "chain"]`
|
||||
- `["filter", "data-fetcher"]` — fires after `data-fetcher` skill completes
|
||||
|
||||
### 4.5 What to pass as chain context
|
||||
|
||||
The chain trigger's synthetic event should include the source skill's final LLM response. This requires capturing the last assistant message from the tool loop in `agent_on_trigger()`. Currently the function doesn't return any output — it just runs and exits.
|
||||
|
||||
**Approach:** After the tool loop at [line 2041](src/agent.c:2041), extract the last assistant message from `messages` array and pass it to `trigger_manager_fire_chains()`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Documentation
|
||||
|
||||
### 5.1 Update `docs/SKILLS.md`
|
||||
|
||||
**File:** [`docs/SKILLS.md`](docs/SKILLS.md:209)
|
||||
|
||||
Expand the Trigger Tags table to show all four types and their filter semantics:
|
||||
|
||||
| Tag | Required | Description |
|
||||
|---|---|---|
|
||||
| `trigger` | Yes | Trigger type: `nostr-subscription`, `webhook`, `cron`, or `chain` |
|
||||
| `filter` | Yes | Type-dependent: JSON filter, empty/unused, cron expression, or source d_tag |
|
||||
| `action` | No | `template` or `llm` — default: `llm` |
|
||||
| `enabled` | No | Whether active — default: `true` |
|
||||
|
||||
Add sections for each new trigger type with examples.
|
||||
|
||||
Update the Future Extensions table to mark webhook, cron, and chain as implemented.
|
||||
|
||||
### 5.2 Update `docs/API.md`
|
||||
|
||||
**File:** [`docs/API.md`](docs/API.md:49)
|
||||
|
||||
Add documentation for the new webhook endpoint:
|
||||
|
||||
```
|
||||
### POST /api/trigger/:d_tag
|
||||
|
||||
Fire a webhook trigger by skill d_tag.
|
||||
|
||||
**URL Parameters:**
|
||||
- `d_tag` — The skill's d_tag identifier
|
||||
|
||||
**Request Body:** Optional JSON payload passed as context to the skill
|
||||
|
||||
**Response:**
|
||||
{
|
||||
"success": true,
|
||||
"d_tag": "my-webhook-skill",
|
||||
"status": "fired"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Build and Push
|
||||
|
||||
1. Run `make -j` and fix any compilation warnings/errors
|
||||
2. Test webhook with: `curl -X POST http://localhost:8484/api/trigger/test-skill -d '{"message":"hello"}'`
|
||||
3. Push with `./increment_and_push.sh "feat: add webhook, cron, and chain trigger types"`
|
||||
|
||||
---
|
||||
|
||||
## Files Modified Summary
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| [`src/trigger_manager.h`](src/trigger_manager.h) | Add `trigger_type_t` enum, extend `active_trigger_t`, update function signatures, add new declarations |
|
||||
| [`src/trigger_manager.c`](src/trigger_manager.c) | Type conversion helpers, update load/add/update functions, cron parser, poll implementation, chain fire function, find function |
|
||||
| [`src/tools.c`](src/tools.c) | Update `execute_skill_create()` and `execute_skill_edit()` validation and call sites |
|
||||
| [`src/http_api.c`](src/http_api.c) | Add webhook route handler and route entry |
|
||||
| [`src/agent.c`](src/agent.c) | Add chain trigger post-execution hook in `agent_on_trigger()` |
|
||||
| [`src/agent.h`](src/agent.h) | No changes needed — `agent_on_trigger()` signature unchanged |
|
||||
| [`docs/SKILLS.md`](docs/SKILLS.md) | Document all three new trigger types |
|
||||
| [`docs/API.md`](docs/API.md) | Document webhook endpoint |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Add `trigger_type_t` enum to `trigger_manager.h`
|
||||
- [ ] Extend `active_trigger_t` struct with `trigger_type`, `last_cron_fire`, `cron_expr`
|
||||
- [ ] Add `trigger_type_from_string()` and `trigger_type_to_string()` declarations and implementations
|
||||
- [ ] Add `trigger_manager_find()` declaration and implementation
|
||||
- [ ] Add `trigger_manager_fire_chains()` declaration and implementation
|
||||
- [ ] Update `trigger_manager_add()` signature with `trigger_type_str` parameter
|
||||
- [ ] Update `trigger_manager_update()` signature with `trigger_type_str` parameter
|
||||
- [ ] Update `trigger_manager_add()` body: set trigger_type, conditional subscription registration
|
||||
- [ ] Update `trigger_manager_update()` body: set trigger_type, conditional subscription
|
||||
- [ ] Update `trigger_manager_load_from_skills()` to accept all 4 trigger types
|
||||
- [ ] Update `trigger_manager_load_from_startup_events()` to accept all 4 trigger types
|
||||
- [ ] Update `trigger_manager_status_json()` to include type field
|
||||
- [ ] Update `execute_skill_create()` validation at line 3603
|
||||
- [ ] Update `execute_skill_create()` `trigger_manager_add()` call at line 3678
|
||||
- [ ] Update `execute_skill_edit()` validation at line 4177
|
||||
- [ ] Update `execute_skill_edit()` `trigger_manager_update()` and `trigger_manager_add()` calls at lines 4213-4224
|
||||
- [ ] Add `handle_trigger_webhook()` handler to `http_api.c`
|
||||
- [ ] Add webhook route to `http_handler()` in `http_api.c`
|
||||
- [ ] Implement `cron_field_matches()` and `cron_matches()` in `trigger_manager.c`
|
||||
- [ ] Implement cron polling in `trigger_manager_poll()`
|
||||
- [ ] Add chain trigger post-execution hook in `agent_on_trigger()`
|
||||
- [ ] Add chain depth recursion protection
|
||||
- [ ] Update `docs/SKILLS.md` with all three new trigger types
|
||||
- [ ] Update `docs/API.md` with webhook endpoint
|
||||
- [ ] Build with `make -j` and verify clean compilation
|
||||
- [ ] Push with `./increment_and_push.sh`
|
||||
@@ -0,0 +1,194 @@
|
||||
# NIP-17 Messaging Implementation Plan
|
||||
|
||||
## Background
|
||||
|
||||
Didactyl currently uses NIP-04 (kind 4) for all DM communication. NIP-17 send support exists via `nostr_handler_send_dm_nip17()` and the `nostr_dm_send_nip17` tool, but there is **no ability to receive NIP-17 messages** and **all agent responses always use NIP-04**.
|
||||
|
||||
The `nostr_core_lib` already has full NIP-17/NIP-59 support including:
|
||||
- `nostr_nip17_create_chat_event()` — create kind 14 rumor
|
||||
- `nostr_nip17_send_dm()` — seal + gift wrap (creates wraps for both recipient AND sender)
|
||||
- `nostr_nip17_receive_dm()` — unwrap gift wrap, unseal rumor, return kind 14
|
||||
- `nostr_nip17_extract_dm_relays()` — parse kind 10050 relay lists
|
||||
|
||||
## Config-Driven Protocol Selection
|
||||
|
||||
### New Config Field
|
||||
|
||||
Add a `dm_protocol` field to the top-level config:
|
||||
|
||||
```json
|
||||
{
|
||||
"dm_protocol": "nip04",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Valid values:
|
||||
- `"nip04"` — NIP-04 only (current behavior, default for backward compatibility)
|
||||
- `"nip17"` — NIP-17 only (subscribe to kind 1059, send via gift wrap)
|
||||
- `"both"` — Subscribe to both kind 4 and kind 1059; reply using whichever protocol the message arrived on
|
||||
|
||||
### Config Struct Change
|
||||
|
||||
In `config.h`, add to `didactyl_config_t`:
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
DM_PROTOCOL_NIP04 = 0,
|
||||
DM_PROTOCOL_NIP17 = 1,
|
||||
DM_PROTOCOL_BOTH = 2
|
||||
} dm_protocol_t;
|
||||
```
|
||||
|
||||
Add `dm_protocol_t dm_protocol;` to the config struct.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Add `dm_protocol` config parsing
|
||||
|
||||
**Files**: `config.h`, `config.c`
|
||||
|
||||
- Add `dm_protocol_t` enum and field to `didactyl_config_t`
|
||||
- Parse `"dm_protocol"` string from JSON in `config_load()`
|
||||
- Default to `DM_PROTOCOL_NIP04` if not specified
|
||||
|
||||
### 2. Add kind 1059 subscription
|
||||
|
||||
**File**: `nostr_handler.c` — `nostr_handler_subscribe_dms()`
|
||||
|
||||
- When `dm_protocol` is `NIP17` or `BOTH`, add `kind: 1059` to the subscription filter
|
||||
- When `dm_protocol` is `NIP04` or `BOTH`, keep `kind: 4` in the filter
|
||||
- The subscription filter becomes `kinds: [4, 1059]` for `BOTH` mode
|
||||
|
||||
### 3. Add NIP-17 receive handling in `on_event()`
|
||||
|
||||
**File**: `nostr_handler.c` — `on_event()`
|
||||
|
||||
Currently `on_event()` rejects anything that is not kind 4. Update to:
|
||||
|
||||
- If kind == 1059: unwrap gift wrap via `nostr_nip17_receive_dm()`, extract sender pubkey from the kind 14 rumor, extract message content, determine sender tier, fire `g_dm_callback`
|
||||
- If kind == 4: existing NIP-04 decrypt path (unchanged)
|
||||
- Track which protocol was used per sender pubkey for reply routing
|
||||
|
||||
### 4. Track protocol per sender for reply routing
|
||||
|
||||
**File**: `nostr_handler.c`
|
||||
|
||||
Add a small cache that maps `sender_pubkey_hex -> last_protocol_used`:
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
char pubkey_hex[65];
|
||||
dm_protocol_t protocol;
|
||||
} sender_protocol_entry_t;
|
||||
|
||||
#define SENDER_PROTOCOL_CACHE_SIZE 64
|
||||
static sender_protocol_entry_t g_sender_protocol_cache[SENDER_PROTOCOL_CACHE_SIZE];
|
||||
```
|
||||
|
||||
When a DM arrives via kind 4, record `NIP04` for that sender. When via kind 1059, record `NIP17`.
|
||||
|
||||
### 5. Add protocol-aware send function
|
||||
|
||||
**File**: `nostr_handler.c`
|
||||
|
||||
Add a new function that routes based on config + sender history:
|
||||
|
||||
```c
|
||||
int nostr_handler_send_dm_auto(const char* recipient_pubkey_hex, const char* message);
|
||||
```
|
||||
|
||||
Logic:
|
||||
- If `dm_protocol == NIP04`: always use `nostr_handler_send_dm()`
|
||||
- If `dm_protocol == NIP17`: always use `nostr_handler_send_dm_nip17()`
|
||||
- If `dm_protocol == BOTH`: check sender protocol cache; use matching protocol, default to NIP-04
|
||||
|
||||
Expose in `nostr_handler.h`.
|
||||
|
||||
### 6. Update agent.c to use auto-routing
|
||||
|
||||
**File**: `agent.c`
|
||||
|
||||
Replace all `nostr_handler_send_dm()` calls with `nostr_handler_send_dm_auto()`. This is a mechanical find-and-replace across ~15 call sites.
|
||||
|
||||
### 7. Add kind 10050 startup event support
|
||||
|
||||
**File**: `config.json.example`
|
||||
|
||||
Add a kind 10050 startup event example so NIP-17 clients can discover the agent's DM relay preferences:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 10050,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["relay", "wss://relay.damus.io"],
|
||||
["relay", "wss://nos.lol"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**File**: `config.c` — startup event parsing already handles arbitrary kinds, so this should work with no code changes.
|
||||
|
||||
### 8. Update startup DM to use auto-routing
|
||||
|
||||
**File**: `main.c`
|
||||
|
||||
The startup status DM at line 255 currently calls `nostr_handler_send_dm()`. Update to `nostr_handler_send_dm_auto()`.
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Config
|
||||
CFG[dm_protocol setting]
|
||||
CFG -->|nip04| NIP04_MODE[NIP-04 only]
|
||||
CFG -->|nip17| NIP17_MODE[NIP-17 only]
|
||||
CFG -->|both| BOTH_MODE[Both protocols]
|
||||
end
|
||||
|
||||
subgraph Subscription
|
||||
SUB[nostr_handler_subscribe_dms]
|
||||
NIP04_MODE --> SUB_K4[Subscribe kind 4]
|
||||
NIP17_MODE --> SUB_K1059[Subscribe kind 1059]
|
||||
BOTH_MODE --> SUB_BOTH[Subscribe kind 4 + 1059]
|
||||
end
|
||||
|
||||
subgraph Receive Path
|
||||
EVT[on_event callback]
|
||||
EVT -->|kind 4| DEC4[NIP-04 decrypt]
|
||||
EVT -->|kind 1059| DEC17[NIP-17 unwrap + unseal]
|
||||
DEC4 --> CACHE[Record sender protocol]
|
||||
DEC17 --> CACHE
|
||||
CACHE --> CB[Fire dm_callback]
|
||||
end
|
||||
|
||||
subgraph Send Path
|
||||
SEND[nostr_handler_send_dm_auto]
|
||||
SEND -->|config=nip04| S4[nostr_handler_send_dm - kind 4]
|
||||
SEND -->|config=nip17| S17[nostr_handler_send_dm_nip17 - kind 1059]
|
||||
SEND -->|config=both| LOOKUP[Check sender protocol cache]
|
||||
LOOKUP -->|sender used nip04| S4
|
||||
LOOKUP -->|sender used nip17| S17
|
||||
LOOKUP -->|unknown| S4
|
||||
end
|
||||
```
|
||||
|
||||
## File Change Summary
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `config.h` | Add `dm_protocol_t` enum and field |
|
||||
| `config.c` | Parse `dm_protocol` from JSON |
|
||||
| `nostr_handler.h` | Add `nostr_handler_send_dm_auto()` declaration |
|
||||
| `nostr_handler.c` | Add kind 1059 subscription, NIP-17 receive in `on_event()`, sender protocol cache, `nostr_handler_send_dm_auto()` |
|
||||
| `agent.c` | Replace `nostr_handler_send_dm()` with `nostr_handler_send_dm_auto()` |
|
||||
| `main.c` | Replace startup DM send with `nostr_handler_send_dm_auto()` |
|
||||
| `config.json.example` | Add `dm_protocol` field and kind 10050 startup event example |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **NIP-04 mode** (default): Verify existing behavior is unchanged
|
||||
2. **NIP-17 mode**: Send a NIP-17 DM from a client like Amethyst/0xchat, verify Didactyl receives and replies via NIP-17
|
||||
3. **Both mode**: Send NIP-04 DM, verify NIP-04 reply; send NIP-17 DM, verify NIP-17 reply
|
||||
4. **Kind 10050**: Verify the relay list event is published on startup and discoverable by NIP-17 clients
|
||||
+11
-16
@@ -106,22 +106,16 @@ Simple YAML-like format parsed in C. Each section has:
|
||||
|---|---|---|
|
||||
| `section` | yes | Section name for logging and API identification |
|
||||
| `role` | yes | Chat message role: `system`, `user`, `assistant`, or `expand` |
|
||||
| `content` | no | Content template with `{{variable}}` placeholders |
|
||||
| `limit` | no | Integer limit for variable-length sections like DM history or notes |
|
||||
| `tool` | recommended | Tool name executed by template builder (for example `nostr_admin_profile`) |
|
||||
| `args` | no | JSON args string passed to the tool (default `{}`) |
|
||||
| `result_field` | no | Field extracted from tool JSON result (default `content`) |
|
||||
| `content` | optional fallback | Literal content when no `tool` is specified |
|
||||
| `limit` | no | Integer limit for variable-length sections like DM history |
|
||||
| `provider` | no | Provider-specific override — see below |
|
||||
|
||||
### Variable Placeholders
|
||||
### Tool-Driven Context Resolution
|
||||
|
||||
| Variable | Source | Description |
|
||||
|---|---|---|
|
||||
| `{{admin_pubkey}}` | `config.admin.pubkey` | Admin hex pubkey |
|
||||
| `{{admin_kind0_json}}` | `nostr_handler_get_admin_kind0_context()` | Admin profile JSON |
|
||||
| `{{admin_kind10002_json}}` | `nostr_handler_get_admin_kind10002_context()` | Admin relay list JSON |
|
||||
| `{{startup_events_json}}` | Serialized startup events | Startup events array |
|
||||
| `{{adopted_skills_content}}` | Adoption list cache | Formatted skill instructions |
|
||||
| `{{admin_notes_content}}` | `nostr_handler_get_admin_kind1_notes_context()` | Recent kind 1 notes |
|
||||
| `{{agent_pubkey}}` | `config.keys.public_key_hex` | Agent own pubkey |
|
||||
| `{{agent_npub}}` | Derived from pubkey | Agent npub |
|
||||
Context sections should use `tool:` directives as the single runtime data path, which removes the legacy per-variable resolver duplication.
|
||||
|
||||
### Special Section Types
|
||||
|
||||
@@ -165,10 +159,11 @@ flowchart TD
|
||||
DM[Incoming DM] --> BUILD[Build context from template]
|
||||
BUILD --> EMIT_SOUL[Emit personality as first system message]
|
||||
EMIT_SOUL --> FOREACH[For each template section]
|
||||
FOREACH --> RESOLVE[Resolve {{variables}} from live data]
|
||||
RESOLVE --> CHECK_PROVIDER{Provider override?}
|
||||
FOREACH --> CALL_TOOL[Execute section tool or use literal content]
|
||||
CALL_TOOL --> PICK_FIELD[Extract result_field or content]
|
||||
PICK_FIELD --> CHECK_PROVIDER{Provider override?}
|
||||
CHECK_PROVIDER -->|Yes| USE_OVERRIDE[Use provider-specific content]
|
||||
CHECK_PROVIDER -->|No| USE_DEFAULT[Use default content]
|
||||
CHECK_PROVIDER -->|No| USE_DEFAULT[Use tool/literal output]
|
||||
USE_OVERRIDE --> EMIT[Emit as chat message]
|
||||
USE_DEFAULT --> EMIT
|
||||
EMIT --> FOREACH
|
||||
|
||||
@@ -194,9 +194,9 @@ New sections in `config.json`:
|
||||
|
||||
### Problem
|
||||
|
||||
Even with Phase 1 protections, the private key (nsec) still lives in Didactyl process memory and in `config.json` on disk. The LLM has `shell_exec` and `file_read` tools — a sufficiently clever prompt injection could theoretically:
|
||||
- Read `config.json` via `file_read` (contains nsec)
|
||||
- Execute `cat /proc/self/maps` or similar via `shell_exec`
|
||||
Even with Phase 1 protections, the private key (nsec) still lives in Didactyl process memory and in `config.json` on disk. The LLM has `local_shell_exec` and `local_file_read` tools — a sufficiently clever prompt injection could theoretically:
|
||||
- Read `config.json` via `local_file_read` (contains nsec)
|
||||
- Execute `cat /proc/self/maps` or similar via `local_shell_exec`
|
||||
- Exfiltrate the key via `nostr_post`
|
||||
|
||||
### Solution: NIP-46 Remote Signer
|
||||
@@ -280,7 +280,7 @@ flowchart LR
|
||||
|
||||
- Private key never in Didactyl process memory
|
||||
- Private key never on disk in config.json
|
||||
- LLM tools (shell_exec, file_read) cannot access the key
|
||||
- LLM tools (local_shell_exec, local_file_read) cannot access the key
|
||||
- Even full process compromise of Didactyl does not leak the signing key
|
||||
- Remote signer can be on a separate hardened machine
|
||||
- Remote signer can implement rate limiting, approval flows, etc.
|
||||
|
||||
+30
-30
@@ -12,12 +12,12 @@ Skills are Nostr events. The tools are thin orchestration wrappers over the exis
|
||||
|
||||
| Kind | Purpose | Replaceable? | Key tag |
|
||||
|---|---|---|---|
|
||||
| `31123` | Public skill definition | Yes (d-tag) | `d=<slug>` |
|
||||
| `31124` | Private skill definition | Yes (d-tag) | `d=<slug>` |
|
||||
| `31123` | Public skill definition | Yes (d-tag) | `d=<d_tag>` |
|
||||
| `31124` | Private skill definition | Yes (d-tag) | `d=<d_tag>` |
|
||||
| `10123` | Public skill adoption list | Yes (replaceable) | `a` refs to 31123 events |
|
||||
|
||||
All skill events carry these standard tags:
|
||||
- `["d", "<slug>"]` — unique identifier within the author's pubkey
|
||||
- `["d", "<d_tag>"]` — unique identifier within the author's pubkey
|
||||
- `["app", "didactyl"]` — app namespace
|
||||
- `["scope", "public"]` or `["scope", "private"]`
|
||||
|
||||
@@ -38,37 +38,37 @@ All skill events carry these standard tags:
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": { "type": "string", "description": "Unique skill identifier (lowercase, hyphens allowed)" },
|
||||
"d_tag": { "type": "string", "description": "Unique skill identifier (lowercase, hyphens allowed)" },
|
||||
"content": { "type": "string", "description": "Skill body — markdown instructions or structured JSON" },
|
||||
"scope": { "type": "string", "description": "public (kind 31123) or private (kind 31124). Default: public" },
|
||||
"description": { "type": "string", "description": "Short one-line description for the skill" },
|
||||
"auto_adopt": { "type": "boolean", "description": "Automatically add to adoption list (kind 10123). Default: true" }
|
||||
},
|
||||
"required": ["slug", "content"]
|
||||
"required": ["d_tag", "content"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Execution logic (`execute_skill_create`):**
|
||||
|
||||
1. Validate `slug` — must be non-empty, lowercase alphanumeric + hyphens, no spaces
|
||||
1. Validate `d_tag` — must be non-empty, lowercase alphanumeric + hyphens, no spaces
|
||||
2. Determine kind: `31123` if scope is `"public"` or absent; `31124` if `"private"`
|
||||
3. Build tags array:
|
||||
- `["d", slug]`
|
||||
- `["d", d_tag]`
|
||||
- `["app", "didactyl"]`
|
||||
- `["scope", scope]`
|
||||
- `["description", description]` if provided
|
||||
4. Call `nostr_handler_publish_kind_event(kind, content, tags, &result)`
|
||||
5. If `auto_adopt` is true (default), update the kind `10123` adoption list:
|
||||
- Query existing `10123` event for own pubkey (same pattern as `execute_nostr_list_manage`)
|
||||
- Add `["a", "31123:<own_pubkey>:<slug>"]` tag if not already present
|
||||
- Add `["a", "31123:<own_pubkey>:<d_tag>"]` tag if not already present
|
||||
- Republish the updated `10123` event
|
||||
6. Return JSON with `success`, `event_id`, `naddr_uri`, `slug`, `adopted`
|
||||
6. Return JSON with `success`, `event_id`, `naddr_uri`, `d_tag`, `adopted`
|
||||
|
||||
**Key design decisions:**
|
||||
- Auto-adopt defaults to `true` — creating a skill you don't adopt is unusual
|
||||
- Private skills (31124) are NOT added to the public adoption list (10123)
|
||||
- Republishing with the same slug replaces the previous version (replaceable event)
|
||||
- Republishing with the same d_tag replaces the previous version (replaceable event)
|
||||
|
||||
---
|
||||
|
||||
@@ -99,7 +99,7 @@ All skill events carry these standard tags:
|
||||
- Private only: `{"kinds": [31124], "authors": [own_pubkey]}`
|
||||
2. Call `nostr_handler_query_json(filter, 8000)`
|
||||
3. Parse results, extract for each event:
|
||||
- `slug` (from d-tag)
|
||||
- `d_tag` (from d-tag)
|
||||
- `kind`
|
||||
- `scope` (from scope tag)
|
||||
- `description` (from description tag, if present)
|
||||
@@ -123,21 +123,21 @@ All skill events carry these standard tags:
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pubkey": { "type": "string", "description": "Hex pubkey of the skill author" },
|
||||
"slug": { "type": "string", "description": "Skill slug (d-tag value)" },
|
||||
"d_tag": { "type": "string", "description": "Skill d_tag (d-tag value)" },
|
||||
"kind": { "type": "integer", "description": "Skill kind (31123 or 31124). Default: 31123" }
|
||||
},
|
||||
"required": ["pubkey", "slug"]
|
||||
"required": ["pubkey", "d_tag"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Execution logic (`execute_skill_adopt`):**
|
||||
|
||||
1. Validate pubkey (64-char hex) and slug (non-empty)
|
||||
1. Validate pubkey (64-char hex) and d_tag (non-empty)
|
||||
2. Default kind to 31123 if not provided
|
||||
3. Build the `a`-tag value: `"<kind>:<pubkey>:<slug>"`
|
||||
3. Build the `a`-tag value: `"<kind>:<pubkey>:<d_tag>"`
|
||||
4. Query existing kind `10123` event for own pubkey
|
||||
5. Check if `["a", "<kind>:<pubkey>:<slug>"]` already exists — if so, return success with `already_adopted: true`
|
||||
5. Check if `["a", "<kind>:<pubkey>:<d_tag>"]` already exists — if so, return success with `already_adopted: true`
|
||||
6. Add the tag, republish `10123`
|
||||
7. Return JSON with `success`, `adopted_address`, `event_id`
|
||||
|
||||
@@ -157,10 +157,10 @@ All skill events carry these standard tags:
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pubkey": { "type": "string", "description": "Hex pubkey of the skill author. Defaults to own pubkey." },
|
||||
"slug": { "type": "string", "description": "Skill slug to remove" },
|
||||
"d_tag": { "type": "string", "description": "Skill d_tag to remove" },
|
||||
"kind": { "type": "integer", "description": "Skill kind (31123 or 31124). Default: 31123" }
|
||||
},
|
||||
"required": ["slug"]
|
||||
"required": ["d_tag"]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -169,7 +169,7 @@ All skill events carry these standard tags:
|
||||
|
||||
1. Default pubkey to own pubkey if not provided
|
||||
2. Default kind to 31123
|
||||
3. Build the `a`-tag value: `"<kind>:<pubkey>:<slug>"`
|
||||
3. Build the `a`-tag value: `"<kind>:<pubkey>:<d_tag>"`
|
||||
4. Query existing kind `10123` event for own pubkey
|
||||
5. Find and remove matching `["a", ...]` tag
|
||||
6. Republish `10123`
|
||||
@@ -211,7 +211,7 @@ All skill events carry these standard tags:
|
||||
- Return skill summaries
|
||||
3. If `query` is provided (keyword search):
|
||||
- Query `{"kinds": [31123]}` with limit
|
||||
- Filter results client-side by matching `query` against slug, description tag, or content
|
||||
- Filter results client-side by matching `query` against d_tag, description tag, or content
|
||||
- Return matching skill summaries
|
||||
4. Default (no params): return own adopted skills from `10123`
|
||||
|
||||
@@ -236,7 +236,7 @@ This is essentially the same pattern already used in `execute_nostr_list_manage(
|
||||
### Shared helper: skill summary extraction
|
||||
|
||||
```c
|
||||
// Extract slug, kind, scope, description, created_at from a skill event JSON
|
||||
// Extract d_tag, kind, scope, description, created_at from a skill event JSON
|
||||
static cJSON* extract_skill_summary(cJSON* event);
|
||||
```
|
||||
|
||||
@@ -269,7 +269,7 @@ flowchart TD
|
||||
1. **Schema registration** — Add 5 new tool definitions in `tools_build_openai_schema_json()` (t22–t26)
|
||||
2. **Execution functions** — Add 5 new `execute_skill_*()` static functions
|
||||
3. **Dispatch** — Add 5 new `strcmp` branches in `tools_execute()`
|
||||
4. **Shared helpers** — Add `fetch_adoption_list_tags()`, `publish_adoption_list()`, `extract_skill_summary()`, and `validate_skill_slug()`
|
||||
4. **Shared helpers** — Add `fetch_adoption_list_tags()`, `publish_adoption_list()`, `extract_skill_summary()`, and `validate_skill_d_tag()`
|
||||
|
||||
### `README.md`
|
||||
|
||||
@@ -284,19 +284,19 @@ flowchart TD
|
||||
|
||||
## Slug Validation Rules
|
||||
|
||||
A valid skill slug must:
|
||||
A valid skill d_tag must:
|
||||
- Be 1–64 characters
|
||||
- Contain only lowercase letters, digits, and hyphens
|
||||
- Not start or end with a hyphen
|
||||
- Not contain consecutive hyphens
|
||||
|
||||
```c
|
||||
static int validate_skill_slug(const char* slug) {
|
||||
if (!slug || slug[0] == '\0' || strlen(slug) > 64) return 0;
|
||||
if (slug[0] == '-') return 0;
|
||||
static int validate_skill_d_tag(const char* d_tag) {
|
||||
if (!d_tag || d_tag[0] == '\0' || strlen(d_tag) > 64) return 0;
|
||||
if (d_tag[0] == '-') return 0;
|
||||
int prev_dash = 0;
|
||||
for (size_t i = 0; slug[i]; i++) {
|
||||
char c = slug[i];
|
||||
for (size_t i = 0; d_tag[i]; i++) {
|
||||
char c = d_tag[i];
|
||||
if (c == '-') {
|
||||
if (prev_dash) return 0;
|
||||
prev_dash = 1;
|
||||
@@ -306,7 +306,7 @@ static int validate_skill_slug(const char* slug) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (slug[strlen(slug) - 1] == '-') return 0;
|
||||
if (d_tag[strlen(d_tag) - 1] == '-') return 0;
|
||||
return 1;
|
||||
}
|
||||
```
|
||||
@@ -315,7 +315,7 @@ static int validate_skill_slug(const char* slug) {
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Shared helpers** — `validate_skill_slug`, `fetch_adoption_list_tags`, `publish_adoption_list`, `extract_skill_summary`
|
||||
1. **Shared helpers** — `validate_skill_d_tag`, `fetch_adoption_list_tags`, `publish_adoption_list`, `extract_skill_summary`
|
||||
2. **`skill_create`** — most important tool, enables the agent to author skills
|
||||
3. **`skill_list`** — lets the agent see what it has published
|
||||
4. **`skill_adopt`** — adopt skills from other authors
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
# Skills Demo Page — Design Document
|
||||
|
||||
## Purpose
|
||||
|
||||
A standalone web page that demonstrates Didactyl skills running in a browser context. The page presents a minimal word-processing interface where users can load text, discover skills from Nostr relays, execute them against the text via an LLM, and edit/republish skill definitions.
|
||||
|
||||
This document is the complete specification for building the page. The front-end project consuming this document has its own LLM calling capability and Nostr connectivity.
|
||||
|
||||
---
|
||||
|
||||
## Concepts
|
||||
|
||||
### What is a Skill?
|
||||
|
||||
A skill is a Nostr event (kind `31123` for public, `31124` for private) that defines a self-contained LLM execution unit. Each skill specifies:
|
||||
|
||||
- A **template** with system and user prompt sections
|
||||
- An **LLM specification** (model preferences with fallback chain)
|
||||
- **Parameters** like temperature and max_tokens
|
||||
- A **description** for human display
|
||||
|
||||
Skills are replaceable events keyed by their `d` tag (the d_tag). Publishing a new event with the same `d` tag replaces the previous version.
|
||||
|
||||
### Skill Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31123,
|
||||
"pubkey": "<author hex pubkey>",
|
||||
"created_at": 1709750000,
|
||||
"content": "{\"description\":\"Check spelling and grammar\",\"context_mode\":\"full\",\"llm\":\"openai/gpt-4o-mini, cheap\",\"tools\":false,\"max_tokens\":2000,\"temperature\":0.1,\"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"],
|
||||
["app", "didactyl"],
|
||||
["scope", "public"],
|
||||
["description", "Spelling and grammar checker"],
|
||||
["t", "text-processing"],
|
||||
["t", "skills-demo"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `content` field is a **JSON string** containing these fields:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `description` | string | yes | Human-readable description |
|
||||
| `context_mode` | string | no | `full` for demo skills (skill provides its own template) |
|
||||
| `llm` | string | no | LLM model preference with fallback chain |
|
||||
| `tools` | bool | no | `false` for text-processing skills |
|
||||
| `max_tokens` | int | no | Max tokens for LLM response |
|
||||
| `temperature` | float | no | Sampling temperature |
|
||||
| `template` | string | yes | The prompt template with `system:` and `user:` sections |
|
||||
|
||||
### Template Format
|
||||
|
||||
Templates use a simple section-based format:
|
||||
|
||||
```
|
||||
system:
|
||||
You are a spelling and grammar checker.
|
||||
|
||||
Rules:
|
||||
- Fix spelling errors
|
||||
- Fix grammar errors
|
||||
|
||||
user:
|
||||
{{message}}
|
||||
```
|
||||
|
||||
The `{{message}}` placeholder is replaced with the user's text at execution time.
|
||||
|
||||
### Skill Address Format
|
||||
|
||||
Skills are referenced by their NIP-33 address: `<kind>:<author_pubkey_hex>:<d_tag>`
|
||||
|
||||
Example: `31123:52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8:spellcheck`
|
||||
|
||||
---
|
||||
|
||||
## Demo Skills
|
||||
|
||||
Four skills are published by the Didactyl agent (pubkey `52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8`). All are tagged with `t:skills-demo` for easy discovery.
|
||||
|
||||
### 1. Spellcheck — `d:spellcheck`
|
||||
|
||||
```
|
||||
system:
|
||||
You are a spelling and grammar checker.
|
||||
|
||||
Rules:
|
||||
- Fix spelling errors
|
||||
- Fix grammar errors
|
||||
- Preserve original formatting and paragraph structure
|
||||
- Preserve line breaks exactly as they appear
|
||||
- Ignore technical terms: API, JSON, HTTP, nostr, pubkey, npub, nsec, NIP
|
||||
- Canadian English preferred
|
||||
- Return ONLY the corrected text, no explanations or commentary
|
||||
|
||||
user:
|
||||
{{message}}
|
||||
```
|
||||
|
||||
- temperature: `0.1`
|
||||
- max_tokens: `4000`
|
||||
|
||||
### 2. Condense — `d:condense-5`
|
||||
|
||||
```
|
||||
system:
|
||||
You are a text condensing editor.
|
||||
|
||||
Rules:
|
||||
- Reduce the text length by approximately 5%
|
||||
- Preserve the original meaning and tone
|
||||
- Preserve paragraph structure
|
||||
- Remove redundant words and phrases
|
||||
- Tighten sentences without changing voice
|
||||
- Do not add new information
|
||||
- Return ONLY the condensed text, no explanations or commentary
|
||||
|
||||
user:
|
||||
{{message}}
|
||||
```
|
||||
|
||||
- temperature: `0.3`
|
||||
- max_tokens: `4000`
|
||||
|
||||
### 3. Translate to Japanese — `d:translate-ja`
|
||||
|
||||
```
|
||||
system:
|
||||
You are a professional translator specializing in English to Japanese translation.
|
||||
|
||||
Rules:
|
||||
- Translate the entire text from English to Japanese
|
||||
- Use natural, fluent Japanese appropriate for the content register
|
||||
- Preserve paragraph structure and formatting
|
||||
- Preserve any technical terms in their commonly accepted Japanese form
|
||||
- If a term has no standard Japanese equivalent, keep the English term in katakana
|
||||
- Return ONLY the Japanese translation, no explanations or commentary
|
||||
|
||||
user:
|
||||
{{message}}
|
||||
```
|
||||
|
||||
- temperature: `0.3`
|
||||
- max_tokens: `4000`
|
||||
|
||||
### 4. Convert to Poem — `d:convert-to-poem`
|
||||
|
||||
```
|
||||
system:
|
||||
You are a creative poet.
|
||||
|
||||
Rules:
|
||||
- Convert the given text into a poem
|
||||
- Capture the key themes and meaning of the original text
|
||||
- Use vivid imagery and poetic devices
|
||||
- Aim for 12-20 lines
|
||||
- Free verse is acceptable but light rhyme is welcome
|
||||
- Return ONLY the poem, no explanations or commentary
|
||||
|
||||
user:
|
||||
{{message}}
|
||||
```
|
||||
|
||||
- temperature: `0.9`
|
||||
- max_tokens: `2000`
|
||||
|
||||
---
|
||||
|
||||
## Discovering Skills from Nostr
|
||||
|
||||
### Query Filter
|
||||
|
||||
To find the demo skills, query relays with this filter:
|
||||
|
||||
```json
|
||||
{
|
||||
"kinds": [31123],
|
||||
"#t": ["skills-demo"],
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
To find skills by a specific author:
|
||||
|
||||
```json
|
||||
{
|
||||
"kinds": [31123],
|
||||
"authors": ["52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8"],
|
||||
"#t": ["skills-demo"],
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
### Recommended Relays
|
||||
|
||||
- `wss://relay.damus.io`
|
||||
- `wss://nos.lol`
|
||||
- `wss://relay.primal.net`
|
||||
|
||||
### Parsing a Skill Event
|
||||
|
||||
1. Receive the event from the relay
|
||||
2. Parse `event.content` as JSON to get the skill definition object
|
||||
3. Extract `description`, `template`, `temperature`, `max_tokens`
|
||||
4. Extract the `d` tag value from `event.tags` as the skill d_tag
|
||||
5. Extract the `description` tag from `event.tags` as a display label
|
||||
6. The skill is ready to display and execute
|
||||
|
||||
---
|
||||
|
||||
## Page Layout
|
||||
|
||||
```
|
||||
+------------------------------------------------------------------+
|
||||
| SKILLS DEMO [dark/light]|
|
||||
+------------------------------------------------------------------+
|
||||
| | |
|
||||
| +------------------------------+ | +---------------------------+|
|
||||
| | TEXT EDITOR | | | SKILLS PANEL ||
|
||||
| | | | | ||
|
||||
| | [large editable textarea ]| | | Loading skills... ||
|
||||
| | [filled with default text ]| | | ||
|
||||
| | [ ]| | | [x] spellcheck ||
|
||||
| | [ ]| | | Spelling and grammar ||
|
||||
| | [ ]| | | checker ||
|
||||
| | [ ]| | | ||
|
||||
| | [ ]| | | [ ] condense-5 ||
|
||||
| | [ ]| | | Reduce text by 5% ||
|
||||
| | [ ]| | | ||
|
||||
| | [ ]| | | [ ] translate-ja ||
|
||||
| | [ ]| | | Translate to Japanese ||
|
||||
| | [ ]| | | ||
|
||||
| | [ ]| | | [ ] convert-to-poem ||
|
||||
| | | | | Convert to poem ||
|
||||
| +------------------------------+ | | | ||
|
||||
| | | [Run Selected Skill] ||
|
||||
| +------------------------------+ | | [Edit Skill] ||
|
||||
| | STATUS BAR | | | ||
|
||||
| | Ready / Running... / Done | | | ─── Skill Details ─── ||
|
||||
| +------------------------------+ | | template: ... ||
|
||||
| | | temperature: 0.1 ||
|
||||
| | | max_tokens: 4000 ||
|
||||
| | +---------------------------+|
|
||||
+------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
### Left Column — Text Editor (65% width)
|
||||
|
||||
- A large `<textarea>` or `contenteditable` div, minimum 500px tall
|
||||
- Pre-filled with default sample text (see Default Text section below)
|
||||
- Fully editable by the user
|
||||
- When a skill runs, the text content is sent as `{{message}}`
|
||||
- After the LLM responds, the textarea content is **replaced** with the result
|
||||
- An undo button restores the previous text (keep a history stack)
|
||||
|
||||
### Right Column — Skills Panel (35% width)
|
||||
|
||||
- **Skills list**: Radio buttons or selectable cards, one skill selected at a time
|
||||
- Each skill card shows:
|
||||
- Skill d_tag (the `d` tag)
|
||||
- Description (from the `description` tag or content field)
|
||||
- Author pubkey (truncated, e.g., `52a3e8...0acb8`)
|
||||
- **Run Selected Skill** button: executes the selected skill against the current text
|
||||
- **Edit Skill** button: opens the skill editor (see Skill Editor section)
|
||||
- **Skill Details** section: shows the full template, temperature, max_tokens for the selected skill
|
||||
- **Refresh Skills** button: re-queries relays for updated skill events
|
||||
|
||||
### Status Bar
|
||||
|
||||
- Shows current state: `Ready`, `Running skill: spellcheck...`, `Done (1.2s)`, or `Error: ...`
|
||||
- Displays token usage if available from the LLM response
|
||||
|
||||
---
|
||||
|
||||
## Skill Execution Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Page as Demo Page
|
||||
participant LLM as LLM Provider
|
||||
|
||||
User->>Page: Select skill + click Run
|
||||
Page->>Page: Parse skill template
|
||||
Page->>Page: Split template into system/user sections
|
||||
Page->>Page: Replace message placeholder with textarea content
|
||||
Page->>LLM: Send system prompt + user message
|
||||
Note over Page,LLM: Use skill temperature and max_tokens
|
||||
LLM-->>Page: Response text
|
||||
Page->>Page: Push current text to undo stack
|
||||
Page->>Page: Replace textarea with response
|
||||
Page->>User: Show completion status
|
||||
```
|
||||
|
||||
### Template Parsing
|
||||
|
||||
The template field uses a simple format with `system:` and `user:` section markers:
|
||||
|
||||
```
|
||||
system:
|
||||
<system prompt content>
|
||||
|
||||
user:
|
||||
<user prompt content with {{message}} placeholder>
|
||||
```
|
||||
|
||||
**Parsing algorithm:**
|
||||
|
||||
1. Split the template string on the line `user:` (case-sensitive, must be at start of line)
|
||||
2. Everything before `user:` is the system section; strip the leading `system:` prefix
|
||||
3. Everything after `user:` is the user section
|
||||
4. In the user section, replace `{{message}}` with the textarea content
|
||||
5. Trim whitespace from both sections
|
||||
|
||||
**Pseudocode:**
|
||||
|
||||
```javascript
|
||||
function parseSkillTemplate(template, userText) {
|
||||
const userMarkerIndex = template.indexOf('\nuser:\n');
|
||||
if (userMarkerIndex === -1) {
|
||||
// Fallback: entire template is system, user text is the message
|
||||
return {
|
||||
system: template.replace(/^system:\n/, '').trim(),
|
||||
user: userText
|
||||
};
|
||||
}
|
||||
|
||||
let systemPart = template.substring(0, userMarkerIndex);
|
||||
systemPart = systemPart.replace(/^system:\n/, '').trim();
|
||||
|
||||
let userPart = template.substring(userMarkerIndex + '\nuser:\n'.length);
|
||||
userPart = userPart.replace('{{message}}', userText).trim();
|
||||
|
||||
return { system: systemPart, user: userPart };
|
||||
}
|
||||
```
|
||||
|
||||
### LLM Call
|
||||
|
||||
With the parsed system and user prompts, call the LLM:
|
||||
|
||||
```javascript
|
||||
const response = await callLLM({
|
||||
messages: [
|
||||
{ role: 'system', content: parsed.system },
|
||||
{ role: 'user', content: parsed.user }
|
||||
],
|
||||
temperature: skill.temperature || 0.7,
|
||||
max_tokens: skill.max_tokens || 2000
|
||||
});
|
||||
```
|
||||
|
||||
The page's own LLM calling capability handles the actual API request. The skill just provides the prompts and parameters.
|
||||
|
||||
---
|
||||
|
||||
## Skill Editor
|
||||
|
||||
When the user clicks **Edit Skill**, a modal or inline editor opens for the selected skill.
|
||||
|
||||
### Editor Fields
|
||||
|
||||
| Field | Input Type | Description |
|
||||
|-------|-----------|-------------|
|
||||
| Slug | text input (readonly for existing, editable for new) | The `d` tag identifier |
|
||||
| Description | text input | One-line human description |
|
||||
| Template | large textarea (monospace) | The full `system:/user:` template |
|
||||
| Temperature | number input (0.0 - 2.0, step 0.1) | LLM sampling temperature |
|
||||
| Max Tokens | number input (100 - 8000) | Maximum response tokens |
|
||||
|
||||
### Editor Actions
|
||||
|
||||
- **Save & Publish**: Builds a new kind `31123` event and publishes to relays
|
||||
- **Cancel**: Closes editor without changes
|
||||
- **New Skill**: Opens editor with blank fields for creating a new skill
|
||||
|
||||
### Publishing a Skill
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Editor as Skill Editor
|
||||
participant Signer as Nostr Signer
|
||||
participant Relay as Nostr Relays
|
||||
|
||||
Editor->>Editor: Build skill content JSON
|
||||
Editor->>Editor: Build event with kind 31123 + tags
|
||||
Editor->>Signer: Sign event
|
||||
Signer-->>Editor: Signed event
|
||||
Editor->>Relay: Publish to connected relays
|
||||
Relay-->>Editor: OK confirmation
|
||||
Editor->>Editor: Update local skill list
|
||||
```
|
||||
|
||||
**Building the event:**
|
||||
|
||||
```javascript
|
||||
function buildSkillEvent(d_tag, description, template, temperature, maxTokens) {
|
||||
const content = JSON.stringify({
|
||||
description: description,
|
||||
context_mode: 'full',
|
||||
llm: 'default',
|
||||
tools: false,
|
||||
max_tokens: maxTokens,
|
||||
temperature: temperature,
|
||||
template: template
|
||||
});
|
||||
|
||||
return {
|
||||
kind: 31123,
|
||||
content: content,
|
||||
tags: [
|
||||
['d', d_tag],
|
||||
['app', 'didactyl'],
|
||||
['scope', 'public'],
|
||||
['description', description],
|
||||
['t', 'text-processing'],
|
||||
['t', 'skills-demo']
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The event must be signed with the user's Nostr private key (via NIP-07 browser extension or the app's own signer) before publishing to relays.
|
||||
|
||||
---
|
||||
|
||||
## Default Text
|
||||
|
||||
Pre-fill the textarea with this sample text that exercises the demo skills well:
|
||||
|
||||
```
|
||||
The Sovereign Agent
|
||||
|
||||
In the not-too-distant future, software agents will roam freely across decentralized networks, communicating through cryptographic protocols that no central authority can censor or control. These agents will learn new capabilities not from app stores or corporate APIs, but from each other — sharing skills as freely as humans share ideas.
|
||||
|
||||
Imagine an agent that wakes up on any computer in the world. You enter twelve words, and there it is — your agent, with all its memories, skills, and personality intact. It doesn't live on a server you rent. It doesn't depend on a company staying in business. It exists wherever you need it, sovereign and portable.
|
||||
|
||||
The key insight is that skills are the new apps. Instead of installing software, agents adopt skills — small, self-contained instructions that teach them how to perform specific tasks. A spelling checker. A translator. A poet. Each skill is just a Nostr event, discoverable by anyone, adoptable by any agent.
|
||||
|
||||
This is not science fiction. The protocols exist today. Nostr provides the communication layer. Bitcoin provides the economic layer. Cryptography provides the trust layer. What remains is to build the agents themselves — and to set them free.
|
||||
|
||||
There are some erors in this text that a good spellchecker should catch. The writting could also be more concise, and it would be intresting to see it translated or converted into verse.
|
||||
```
|
||||
|
||||
Note: The last paragraph intentionally contains spelling errors (erors, writting, intresting) to demonstrate the spellcheck skill.
|
||||
|
||||
---
|
||||
|
||||
## Undo/Redo
|
||||
|
||||
Maintain a text history stack:
|
||||
|
||||
```javascript
|
||||
const textHistory = [];
|
||||
let historyIndex = -1;
|
||||
|
||||
function pushHistory(text) {
|
||||
// Truncate any forward history
|
||||
textHistory.splice(historyIndex + 1);
|
||||
textHistory.push(text);
|
||||
historyIndex = textHistory.length - 1;
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (historyIndex > 0) {
|
||||
historyIndex--;
|
||||
return textHistory[historyIndex];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (historyIndex < textHistory.length - 1) {
|
||||
historyIndex++;
|
||||
return textHistory[historyIndex];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- Push the current text to history **before** replacing it with the LLM response
|
||||
- Push the initial default text as the first history entry
|
||||
- Show Undo/Redo buttons below the textarea
|
||||
|
||||
---
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
- On screens wider than 900px: two-column layout as shown
|
||||
- On screens narrower than 900px: stack vertically — text editor on top, skills panel below
|
||||
- The textarea should have a minimum height of 400px and resize vertically
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| No relays connected | Show "No relay connection" in skills panel with retry button |
|
||||
| No skills found | Show "No skills found. Try different relays or create a new skill." |
|
||||
| Skill content parse error | Show "Invalid skill format" badge on the skill card, disable Run |
|
||||
| LLM call fails | Show error in status bar, do not replace textarea content |
|
||||
| Publish fails | Show error in editor, keep editor open for retry |
|
||||
|
||||
---
|
||||
|
||||
## Summary of Nostr Operations
|
||||
|
||||
| Operation | Kind | Direction | When |
|
||||
|-----------|------|-----------|------|
|
||||
| Discover skills | `31123` | Read from relays | Page load + refresh |
|
||||
| Execute skill | — | N/A (local LLM call) | User clicks Run |
|
||||
| Publish skill | `31123` | Write to relays | User saves in editor |
|
||||
|
||||
The page does NOT need to interact with the Didactyl agent's HTTP API. All skill discovery and publishing happens directly via Nostr relays. Skill execution happens locally using the page's own LLM capability.
|
||||
@@ -0,0 +1,252 @@
|
||||
# Plan: Unify Template Variables with Tools
|
||||
|
||||
## Problem
|
||||
|
||||
The soul template system (`---template---` in kind 31120) uses `{{variable}}` placeholders that are resolved by a hardcoded C function (`agent_template_resolve_var()`). This is a parallel data-fetching system alongside the existing tool registry. Every new context data source requires:
|
||||
|
||||
1. Adding a C function to produce the data
|
||||
2. Adding an `if (strcmp(...))` branch in the resolver
|
||||
3. Documenting the new variable name
|
||||
4. Hoping the template author uses the exact right name
|
||||
|
||||
This caused the bug that started this investigation — 5 out of 9 template variables were misspelled/mismatched, producing empty context sections and a provider rejection.
|
||||
|
||||
## Goal
|
||||
|
||||
**Eliminate template variables entirely.** Template sections fetch their data by calling tools. The tool registry is the single mechanism for getting data into context.
|
||||
|
||||
## Design
|
||||
|
||||
### New Template Syntax
|
||||
|
||||
Replace `content: |` with `tool:` and optional `args:` directives:
|
||||
|
||||
```yaml
|
||||
---template---
|
||||
|
||||
- section: admin_identity
|
||||
role: system
|
||||
tool: admin_identity
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_profile
|
||||
role: system
|
||||
tool: nostr_admin_profile
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_contacts
|
||||
role: system
|
||||
tool: nostr_admin_contacts
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_relays
|
||||
role: system
|
||||
tool: nostr_admin_relays
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_notes
|
||||
role: system
|
||||
tool: nostr_admin_notes
|
||||
skip_if_empty: true
|
||||
|
||||
- section: tools
|
||||
role: system
|
||||
tool: tool_list
|
||||
skip_if_empty: true
|
||||
|
||||
- section: tasks
|
||||
role: system
|
||||
tool: task_list
|
||||
skip_if_empty: true
|
||||
|
||||
- section: dm_history
|
||||
role: expand
|
||||
limit: 12
|
||||
|
||||
- section: conversation
|
||||
role: user
|
||||
tool: message_current
|
||||
skip_if_empty: true
|
||||
```
|
||||
|
||||
A section can still use the old `content:` with `{{variables}}` for static text or mixed content. But the primary mechanism for dynamic data is `tool:`.
|
||||
|
||||
### How It Works
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
DM[Incoming DM] --> BUILD[Build context from template]
|
||||
BUILD --> SOUL[Emit personality as system msg]
|
||||
SOUL --> LOOP[For each template section]
|
||||
LOOP --> CHECK{Has tool: directive?}
|
||||
CHECK -->|Yes| EXEC["tools_execute(tool_name, args)"]
|
||||
EXEC --> FORMAT[Extract content from JSON result]
|
||||
FORMAT --> EMIT[Emit as chat message]
|
||||
CHECK -->|No| RESOLVE["Resolve {{vars}} from content template<br/>(legacy path, eventually removed)"]
|
||||
RESOLVE --> EMIT
|
||||
EMIT --> LOOP
|
||||
LOOP --> DONE[Complete messages array]
|
||||
```
|
||||
|
||||
### New Context Tools
|
||||
|
||||
Add lightweight "context tools" that read from cached in-memory data. These are fast (no network), deterministic, and use the same `tools_execute()` interface as everything else.
|
||||
|
||||
| Tool | Returns | Source |
|
||||
|---|---|---|
|
||||
| `admin_identity` | Admin pubkey + verification text | config + sender tier |
|
||||
| `nostr_admin_profile` | Admin kind 0 profile JSON | cached `g_admin_kind0_json` |
|
||||
| `nostr_admin_contacts` | Admin kind 3 contacts JSON array | cached `g_admin_wot_contacts` |
|
||||
| `nostr_admin_relays` | Admin kind 10002 relay list JSON | cached `g_admin_kind10002_json` |
|
||||
| `nostr_admin_notes` | Admin recent kind 1 notes | cached `g_admin_kind1_notes` |
|
||||
| `task_list` | Current task list from tasks.json | file read (already exists as `task_manage` with `action: list`) |
|
||||
| `message_current` | Current user message text | passed via tool context |
|
||||
| `agent_identity` | Agent pubkey, npub | config |
|
||||
|
||||
Existing tools that already work for context:
|
||||
- `tool_list` — returns tool schemas (already exists)
|
||||
- `task_manage` with `{"action":"list"}` — returns tasks (already exists)
|
||||
- `local_file_read` — can read any file (already exists)
|
||||
|
||||
### Tool Result → Message Content
|
||||
|
||||
Tool results are JSON objects like `{"success":true,"content":"..."}`. The template system extracts the `content` field (or a configurable field) as the message text. If the tool returns an error or empty content, and `skip_if_empty: true` is set, the section is omitted.
|
||||
|
||||
For tools that return structured data (like `tool_list` returning a schema array), the template can specify a `result_field` or `format` to control extraction:
|
||||
|
||||
```yaml
|
||||
- section: tools
|
||||
role: system
|
||||
tool: tool_list
|
||||
result_field: tools
|
||||
skip_if_empty: true
|
||||
```
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
#### Phase 1: Add context tools + tool: directive support
|
||||
|
||||
1. Add new `context_*` tools to `tools.c` that wrap the existing cached data getters
|
||||
2. Extend `prompt_template_section_t` with `tool_name` and `tool_args` fields
|
||||
3. Extend `prompt_template_parse()` to recognize `tool:` and `args:` directives
|
||||
4. Extend `prompt_template_build_messages()` to call `tools_execute()` when a section has `tool_name` set
|
||||
5. Update the soul template in `config.jsonc` to use `tool:` directives
|
||||
6. Keep `content:` + `{{variable}}` working as a fallback for backward compatibility
|
||||
|
||||
#### Phase 2: Migrate all sections to tool-based
|
||||
|
||||
1. Convert all template sections from `content: {{variable}}` to `tool:` directives
|
||||
2. Verify all context data flows through tools
|
||||
3. Update `context_template.md` to reflect new syntax
|
||||
|
||||
#### Phase 3: Remove legacy variable resolver
|
||||
|
||||
1. Remove `agent_template_resolve_var()` and all its helper functions that are now redundant
|
||||
2. Remove `prompt_var_resolver_fn` callback from `prompt_template_build_messages()`
|
||||
3. Clean up dead code in `agent.c`
|
||||
4. Update all documentation
|
||||
|
||||
### Template Section Struct Changes
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
char name[PROMPT_TEMPLATE_MAX_NAME_LEN];
|
||||
char role[PROMPT_TEMPLATE_MAX_ROLE_LEN];
|
||||
char* content_template; // legacy: {{variable}} content
|
||||
char* tool_name; // NEW: tool to call for content
|
||||
char* tool_args; // NEW: JSON args for tool call
|
||||
char* result_field; // NEW: which JSON field to extract (default: "content")
|
||||
int limit;
|
||||
int skip_if_empty;
|
||||
char* provider_name;
|
||||
char* provider_content_template;
|
||||
} prompt_template_section_t;
|
||||
```
|
||||
|
||||
### Soul Template Example (After Migration)
|
||||
|
||||
```
|
||||
# Didactyl Agent
|
||||
|
||||
You are Didactyl, a sovereign AI agent living on Nostr.
|
||||
...
|
||||
|
||||
---template---
|
||||
|
||||
- section: admin_identity
|
||||
role: system
|
||||
tool: admin_identity
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_profile
|
||||
role: system
|
||||
tool: nostr_admin_profile
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_contacts
|
||||
role: system
|
||||
tool: nostr_admin_contacts
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_relays
|
||||
role: system
|
||||
tool: nostr_admin_relays
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_notes
|
||||
role: system
|
||||
tool: nostr_admin_notes
|
||||
skip_if_empty: true
|
||||
|
||||
- section: tools
|
||||
role: system
|
||||
tool: tool_list
|
||||
result_field: tools
|
||||
skip_if_empty: true
|
||||
|
||||
- section: tasks
|
||||
role: system
|
||||
tool: task_manage
|
||||
args: {"action":"list"}
|
||||
result_field: tasks
|
||||
skip_if_empty: true
|
||||
|
||||
- section: dm_history
|
||||
role: expand
|
||||
limit: 12
|
||||
|
||||
- section: conversation
|
||||
role: user
|
||||
tool: message_current
|
||||
skip_if_empty: true
|
||||
```
|
||||
|
||||
### What Gets Deleted Eventually
|
||||
|
||||
- `agent_template_resolve_var()` and all its `if (strcmp(...))` branches
|
||||
- `build_tool_schemas_json_string()`
|
||||
- `build_tasks_content_string()` (replaced by `task_manage` tool)
|
||||
- `build_admin_recent_posts_text()` (replaced by `nostr_admin_notes` tool)
|
||||
- `build_admin_profile_plain_text()` (replaced by `nostr_admin_profile` tool)
|
||||
- `build_admin_relay_list_plain_text()` (replaced by `nostr_admin_relays` tool)
|
||||
- `build_sender_verification_text()` (folded into `admin_identity` tool)
|
||||
- `build_startup_events_json_string()` (replaced by tool if needed)
|
||||
- `build_adopted_skills_payload_string()` (replaced by tool if needed)
|
||||
- `nostr_handler_get_admin_kind3_context()` (just added, will be replaced by tool)
|
||||
- The `prompt_var_resolver_fn` callback type
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Single data-fetching mechanism** — tools are the only way to get data
|
||||
2. **No more variable name mismatches** — tool names are validated at registration
|
||||
3. **User-configurable context** — admin can add any tool output to context by editing the soul template
|
||||
4. **Testable** — `--test-tool nostr_admin_profile` shows exactly what goes into context
|
||||
5. **Self-documenting** — `tool_list` shows all available context tools with descriptions
|
||||
6. **Extensible** — new tools automatically become available as context sources
|
||||
7. **Provider overrides still work** — `provider:` directive can still override formatting per-provider
|
||||
|
||||
### Risks
|
||||
|
||||
1. **Performance** — Tool calls add function dispatch overhead vs direct variable resolution. Mitigated: context tools read cached data, no network calls.
|
||||
2. **Backward compatibility** — Old soul templates with `{{variables}}` would break. Mitigated: Phase 1 keeps both paths working.
|
||||
3. **Tool context threading** — `tools_execute()` needs access to the tools context, which `prompt_template_build_messages()` doesn't currently have. Solution: pass `tools_context_t*` to the build function.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Didactyl Tool Naming — Final List
|
||||
|
||||
## Decisions
|
||||
|
||||
- `context_*` tools merged into existing categories with proper names
|
||||
- Local tools get `local_` prefix
|
||||
- `context_user_message` removed from LLM schema (template-only)
|
||||
- `nostr_post_readme` kept — a post is different from a longform note
|
||||
- `nostr_file_md_to_longform_post` kept as-is
|
||||
- `task_manage` kept as-is
|
||||
|
||||
---
|
||||
|
||||
## Complete Tool List (sorted by category prefix)
|
||||
|
||||
### `admin_` — Administrator Metadata
|
||||
|
||||
| # | Name | Old Name | Description |
|
||||
|---|---|---|---|
|
||||
| 1 | `admin_identity` | `context_admin_identity` | Admin pubkey, sender tier, verification metadata |
|
||||
|
||||
### `agent_` — Agent Self-Metadata
|
||||
|
||||
| # | Name | Old Name | Description |
|
||||
|---|---|---|---|
|
||||
| 2 | `agent_identity` | `context_agent_identity` | Agent pubkey hex + npub |
|
||||
| 3 | `agent_version` | `my_version` | Didactyl version and build metadata |
|
||||
|
||||
### `local_` — Local / Host Tools
|
||||
|
||||
| # | Name | Old Name | Description |
|
||||
|---|---|---|---|
|
||||
| 4 | `local_shell_exec` | `shell_exec` | Execute a shell command |
|
||||
| 5 | `local_file_read` | `file_read` | Read a local file as text |
|
||||
| 6 | `local_file_write` | `file_write` | Write text to a local file |
|
||||
| 7 | `local_http_fetch` | `http_fetch` | Fetch HTTP/S resources |
|
||||
|
||||
### `model_` — LLM / Model Management
|
||||
|
||||
| # | Name | Old Name | Description |
|
||||
|---|---|---|---|
|
||||
| 8 | `model_get` | — | Get current active LLM config |
|
||||
| 9 | `model_set` | — | Update active LLM config and persist |
|
||||
| 10 | `model_list` | — | List available model IDs from provider |
|
||||
|
||||
### `nostr_` — Nostr Protocol Tools
|
||||
|
||||
| # | Name | Old Name | Description |
|
||||
|---|---|---|---|
|
||||
| 11 | `nostr_post` | — | Publish a Nostr event to connected relays |
|
||||
| 12 | `nostr_post_readme` | — | Publish README.md as kind 30023 |
|
||||
| 13 | `nostr_file_md_to_longform_post` | — | Read markdown file and publish as kind 30023 longform |
|
||||
| 14 | `nostr_delete` | — | Request deletion of events — NIP-09 kind 5 |
|
||||
| 15 | `nostr_react` | — | React to event — NIP-25 kind 7 |
|
||||
| 16 | `nostr_query` | — | Query events from relays using a filter |
|
||||
| 17 | `nostr_profile_get` | — | Look up kind 0 profile by pubkey |
|
||||
| 18 | `nostr_nip05_lookup` | — | Look up/verify NIP-05 identifier |
|
||||
| 19 | `nostr_admin_profile` | `context_admin_profile` | Get admin kind 0 profile from cache |
|
||||
| 20 | `nostr_admin_contacts` | `context_admin_contacts` | Get admin kind 3 contacts from cache |
|
||||
| 21 | `nostr_admin_relays` | `context_admin_relays` | Get admin kind 10002 relay list from cache |
|
||||
| 22 | `nostr_admin_notes` | `context_admin_notes` | Get admin kind 1 recent notes from cache |
|
||||
| 23 | `nostr_dm_send` | — | Send NIP-04 encrypted DM |
|
||||
| 24 | `nostr_dm_send_nip17` | — | Send NIP-17 gift-wrap DM |
|
||||
| 25 | `nostr_encrypt` | — | Encrypt plaintext — NIP-44 |
|
||||
| 26 | `nostr_decrypt` | — | Decrypt ciphertext — NIP-44 |
|
||||
| 27 | `nostr_encode` | — | Encode entity into nostr: URI |
|
||||
| 28 | `nostr_decode` | — | Decode bech32/nostr: URI |
|
||||
| 29 | `nostr_pubkey` | — | Return agent pubkey hex |
|
||||
| 30 | `nostr_npub` | — | Return agent pubkey as npub |
|
||||
| 31 | `nostr_relay_status` | — | Get connection status for all relays |
|
||||
| 32 | `nostr_relay_info` | — | Fetch NIP-11 relay info document |
|
||||
| 33 | `nostr_list_manage` | — | Add/remove tags in replaceable list events — NIP-51 |
|
||||
|
||||
### `skill_` — Skill Management
|
||||
|
||||
| # | Name | Old Name | Description |
|
||||
|---|---|---|---|
|
||||
| 34 | `skill_create` | — | Create/update skill definition |
|
||||
| 35 | `skill_list` | — | List agent published skills |
|
||||
| 36 | `skill_adopt` | — | Adopt a skill — add to kind 10123 |
|
||||
| 37 | `skill_remove` | — | Remove skill from adoption list |
|
||||
| 38 | `skill_search` | — | Search public skills |
|
||||
|
||||
### `task_` — Task Management
|
||||
|
||||
| # | Name | Old Name | Description |
|
||||
|---|---|---|---|
|
||||
| 39 | `task_manage` | — | CRUD for agent task memory |
|
||||
| 40 | `task_list` | `context_tasks` | Get current task list — read-only |
|
||||
|
||||
### Template-Only (not in LLM schema)
|
||||
|
||||
| # | Name | Old Name | Description |
|
||||
|---|---|---|---|
|
||||
| 41 | `message_current` | `context_user_message` | Current user message text — template assembly only |
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
| Old Name | New Name | Change Type |
|
||||
|---|---|---|
|
||||
| `context_admin_identity` | `admin_identity` | Rename |
|
||||
| `context_agent_identity` | `agent_identity` | Rename |
|
||||
| `context_admin_profile` | `nostr_admin_profile` | Rename + recategorize |
|
||||
| `context_admin_contacts` | `nostr_admin_contacts` | Rename + recategorize |
|
||||
| `context_admin_relays` | `nostr_admin_relays` | Rename + recategorize |
|
||||
| `context_admin_notes` | `nostr_admin_notes` | Rename + recategorize |
|
||||
| `context_tasks` | `task_list` | Rename + recategorize |
|
||||
| `context_user_message` | `message_current` | Rename + remove from LLM schema |
|
||||
| `my_version` | `agent_version` | Rename |
|
||||
| `shell_exec` | `local_shell_exec` | Add prefix |
|
||||
| `file_read` | `local_file_read` | Add prefix |
|
||||
| `file_write` | `local_file_write` | Add prefix |
|
||||
| `http_fetch` | `local_http_fetch` | Add prefix |
|
||||
|
||||
**Total: 13 renames, 0 removals, 0 additions**
|
||||
@@ -0,0 +1,360 @@
|
||||
# Tool Orchestration Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This plan covers four related features:
|
||||
1. **Slash commands** — direct tool execution bypassing the LLM
|
||||
2. **Skill-forward execution (`/run` + `skill_run`)** — one-shot skill invocation with LLM, including external skill sharing
|
||||
3. **Skill-tool maturity levels** — skills that register as callable tools with promotion workflow
|
||||
4. **Deterministic step executor** — hardened skills that run without LLM involvement
|
||||
|
||||
## 1. Slash Commands (Direct Tool Execution)
|
||||
|
||||
### Behavior
|
||||
When a message starts with `/`, bypass the LLM entirely and execute the tool directly.
|
||||
|
||||
```
|
||||
/local_shell_exec {"command": "ls -la"}
|
||||
/nostr_query {"filter": {"kinds": [1], "limit": 5}}
|
||||
/nostr_nip05_lookup {"identifier": "jack@cash.app"}
|
||||
```
|
||||
|
||||
### Parsing Rules
|
||||
- `/tool_name` — call tool with empty args `{}`
|
||||
- `/tool_name {"key": "value"}` — call tool with JSON args
|
||||
- `/tool_name plain text` — wrap as `{"input": "plain text"}` (convenience)
|
||||
- `/help` — list available tools
|
||||
- `/help tool_name` — show tool schema
|
||||
|
||||
### Implementation
|
||||
1. In [`agent_on_message()`](../src/agent.c:1453), check if `message[0] == '/'`
|
||||
2. Parse tool name (everything between `/` and first space or end)
|
||||
3. Parse args (everything after tool name, try JSON first, fall back to string wrapper)
|
||||
4. Call [`tools_execute()`](../src/tools.c) directly
|
||||
5. Send result as DM — no LLM round-trip
|
||||
6. Still log to context.log.md with `phase=direct_tool_exec`
|
||||
|
||||
### Special Slash Commands
|
||||
- `/help` — list available tools
|
||||
- `/help tool_name` — show tool schema
|
||||
- `/run` — skill-forward execution (see section 2)
|
||||
|
||||
### Security
|
||||
- Only admin tier can use slash commands (same as current tool policy)
|
||||
- Slash commands respect the same `tools.enabled` and `security.admin.tools_enabled` config flags
|
||||
|
||||
## 2. Skill-Forward Execution
|
||||
|
||||
### The Problem
|
||||
|
||||
Today, skills are passive — they're injected into the LLM context via [`append_adopted_skills_context()`](../src/agent.c:1418) and the LLM decides when they're relevant. There's no way to say "run this specific skill right now" and there's no way to try someone else's skill without permanently adopting it.
|
||||
|
||||
### Three Invocation Layers
|
||||
|
||||
| Layer | How it works | LLM? | Persists? |
|
||||
|-------|-------------|------|-----------|
|
||||
| **Passive/adopted** | Skill instructions injected into every conversation context | Yes, LLM decides relevance | Yes — in kind 10123 adoption list |
|
||||
| **Skill-forward** | Skill instructions become the primary system prompt; LLM executes them | Yes, but constrained | No — one-shot execution |
|
||||
| **Hardened** | Deterministic step executor, no LLM | No | Yes — adopted skill with `execution: hardened` |
|
||||
|
||||
### Entry Points
|
||||
|
||||
#### A. `/run` slash command (admin direct invocation)
|
||||
|
||||
```
|
||||
/run deploy-website staging # run own adopted skill by d_tag
|
||||
/run 31123:<pubkey>:deploy-website staging # run anyone's skill by address
|
||||
/run deploy-website {"target": "production"} # JSON args
|
||||
```
|
||||
|
||||
Parsing:
|
||||
1. First token after `/run` is the skill identifier (d_tag or `kind:pubkey:d_tag` address)
|
||||
2. Everything after is args (try JSON first, fall back to `{"input": "plain text"}`)
|
||||
|
||||
#### B. `skill_run` tool (LLM-mediated invocation)
|
||||
|
||||
The LLM can invoke skills on behalf of the admin during conversation:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "skill_run",
|
||||
"description": "Fetch and execute a skill one-shot without adopting it. Works with own adopted skills by d_tag or any public skill by address.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"d_tag": { "type": "string", "description": "Skill d_tag for own adopted skills" },
|
||||
"address": { "type": "string", "description": "Full skill address: kind:pubkey:d_tag" },
|
||||
"pubkey": { "type": "string", "description": "Author pubkey, used with d_tag to form address" },
|
||||
"args": { "type": "string", "description": "Arguments or context to pass to the skill" },
|
||||
"sandbox": { "type": "boolean", "description": "Override sandbox setting. Default: true for external, false for own skills" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This enables natural conversation like:
|
||||
> "My friend @jack just published a skill called summarize-thread. Try it on the latest thread in my feed."
|
||||
|
||||
The agent would `skill_search` to find it, then `skill_run` to execute it.
|
||||
|
||||
### Execution Flow
|
||||
|
||||
Both `/run` and `skill_run` use the same underlying executor:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[/run or skill_run called] --> B{Skill identifier type?}
|
||||
B -->|d_tag only| C[Look up in adopted skills cache]
|
||||
B -->|address or pubkey+d_tag| D[Fetch skill event from Nostr]
|
||||
C --> E{Found?}
|
||||
D --> E
|
||||
E -->|No| F[Return error: skill not found]
|
||||
E -->|Yes| G{External skill?}
|
||||
G -->|Yes| H[Apply sandbox - restrict tools]
|
||||
G -->|No| I[Full tool access]
|
||||
H --> J[Build skill-forward prompt]
|
||||
I --> J
|
||||
J --> K[System: base context + skill instructions]
|
||||
K --> L[User: args as user message]
|
||||
L --> M[LLM call with tools]
|
||||
M --> N[Return result to caller]
|
||||
```
|
||||
|
||||
### Skill-Forward Prompt Construction
|
||||
|
||||
Reuses the pattern from [`agent_on_trigger()`](../src/agent.c:1837):
|
||||
|
||||
```
|
||||
[base system context / soul]
|
||||
|
||||
Skill execution context:
|
||||
- You are executing a specific skill on demand.
|
||||
- Follow the skill instructions below precisely.
|
||||
- The user's arguments provide the context for this execution.
|
||||
- Keep output concise and actionable.
|
||||
|
||||
Skill d_tag: deploy-website
|
||||
Skill address: 31123:<pubkey>:deploy-website
|
||||
Skill source: [own | external:<author_display_name>]
|
||||
|
||||
Skill instructions:
|
||||
[skill content here]
|
||||
```
|
||||
|
||||
User message:
|
||||
```
|
||||
[args provided by caller]
|
||||
```
|
||||
|
||||
The prompt is so skill-forward that the LLM has no real option but to execute the skill instructions against the provided args.
|
||||
|
||||
### Sandbox for External Skills
|
||||
|
||||
When executing a skill from another author (not own pubkey), a **tool sandbox** is applied by default:
|
||||
|
||||
**Allowed tools (safe/read-only):**
|
||||
- `nostr_query` — read Nostr events
|
||||
- `nostr_nip05_lookup` — NIP-05 lookups
|
||||
- `nostr_post` — publish events (the agent signs, so this is safe)
|
||||
- `nostr_list_manage` — manage lists
|
||||
- `skill_list`, `skill_search` — read skill metadata
|
||||
|
||||
**Blocked tools (destructive/dangerous):**
|
||||
- `local_shell_exec` — arbitrary command execution
|
||||
- `local_file_read`, `local_file_write` — filesystem access
|
||||
- Any future tools marked as `destructive: true`
|
||||
|
||||
**Override:** The admin can explicitly opt in to full tool access:
|
||||
- `/run --unsafe 31123:<pubkey>:risky-skill args`
|
||||
- `skill_run` with `sandbox: false`
|
||||
|
||||
### Skill Sharing on Nostr
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Friend creates skill] -->|kind 31123| B[Published on Nostr relays]
|
||||
B --> C{How do you find it?}
|
||||
C -->|skill_search popular:true| D[Discovery via WoT adoption lists]
|
||||
C -->|Friend tells you the d_tag| E[Direct reference]
|
||||
C -->|skill_search pubkey:friend| F[Browse friends skills]
|
||||
D --> G[skill_run - try it once, sandboxed]
|
||||
E --> G
|
||||
F --> G
|
||||
G -->|Liked it?| H{Adopt?}
|
||||
H -->|Yes| I[skill_adopt - permanent]
|
||||
H -->|No| J[Done - nothing persisted]
|
||||
I --> K[Shows in adopted skills context]
|
||||
K --> L[Agent uses it automatically]
|
||||
L -->|Or invoke directly| M[/run skill-d_tag args]
|
||||
```
|
||||
|
||||
## 3. Skill-Tool Maturity Levels
|
||||
|
||||
Skills can declare an execution maturity level that determines how they run:
|
||||
|
||||
| Level | Execution | LLM? | Use case |
|
||||
|-------|-----------|-------|----------|
|
||||
| `draft` | LLM interprets procedure text from skill instructions | Yes | Exploring and iterating on a workflow |
|
||||
| `guided` | LLM with forced tool_choice + parameter defaults | Yes, constrained | Workflow is stable but needs LLM judgment |
|
||||
| `hardened` | Deterministic step executor, no LLM | No | Workflow is proven and should run exactly as defined |
|
||||
|
||||
### Skill Definition Extensions
|
||||
|
||||
```yaml
|
||||
kind: 31123
|
||||
d: deploy_website
|
||||
execution: hardened
|
||||
tool_schema:
|
||||
name: deploy_website
|
||||
description: Build and deploy the static website
|
||||
parameters:
|
||||
target:
|
||||
type: string
|
||||
enum: [staging, production]
|
||||
default: staging
|
||||
steps:
|
||||
- tool: local_shell_exec
|
||||
args:
|
||||
command: "make build TARGET={{target}}"
|
||||
- tool: local_shell_exec
|
||||
args:
|
||||
command: "rsync -av dist/ server:/var/www/{{target}}/"
|
||||
- return: "Deployed to {{target}}"
|
||||
```
|
||||
|
||||
### How Each Level Works
|
||||
|
||||
**draft:** Current behavior. Skill instructions are injected into context. LLM reads them and decides which tools to call. No special handling needed.
|
||||
|
||||
**guided:** Agent sets `tool_choice` to the skill's preferred tool. Parameter defaults from the skill are merged with the model's generated arguments (skill defaults win on conflict). Reduces LLM freedom while still allowing it to fill in dynamic values.
|
||||
|
||||
**hardened:** Agent executes the `steps` array directly using the deterministic step executor. No LLM call at all. The skill becomes equivalent to a slash command.
|
||||
|
||||
### Promotion Workflow
|
||||
1. Admin iterates with LLM on a task (draft)
|
||||
2. Admin saves working procedure as a skill: `skill_create` with `execution: draft`
|
||||
3. Admin tests, refines, promotes: update skill to `execution: guided`
|
||||
4. Once proven reliable, promote to `execution: hardened` with explicit `steps`
|
||||
5. Hardened skills become available as slash commands: `/deploy_website {"target": "production"}`
|
||||
|
||||
## 4. Deterministic Step Executor
|
||||
|
||||
A simple sequential executor for hardened skills.
|
||||
|
||||
### Step Types
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
# Execute a tool
|
||||
- tool: local_shell_exec
|
||||
args: {command: "ls -la"}
|
||||
save_as: listing # optional: save result to variable
|
||||
|
||||
# Execute a tool with variable substitution
|
||||
- tool: nostr_post
|
||||
args:
|
||||
kind: 30023
|
||||
content: "{{file_content}}"
|
||||
tags: [["d", "{{d_tag}}"]]
|
||||
|
||||
# Conditional - simple
|
||||
- if: "{{listing.success}}"
|
||||
then:
|
||||
- tool: local_shell_exec
|
||||
args: {command: "echo done"}
|
||||
else:
|
||||
- return: "Failed: {{listing.error}}"
|
||||
|
||||
# Return final result
|
||||
- return: "Published to {{d_tag}}"
|
||||
```
|
||||
|
||||
### Variable Substitution
|
||||
- `{{param_name}}` — from tool parameters provided by caller
|
||||
- `{{step_name.field}}` — from a previous step's result (requires `save_as`)
|
||||
- Simple string replacement, no expression evaluation
|
||||
|
||||
### Implementation in C
|
||||
- Parse `steps` array from skill content (JSON or YAML)
|
||||
- Iterate steps sequentially
|
||||
- For each `tool` step: call [`tools_execute()`](../src/tools.c), optionally save result
|
||||
- For each `return` step: substitute variables and return string
|
||||
- For each `if` step: evaluate truthiness of variable, branch accordingly
|
||||
- Total implementation: ~200-400 lines of C
|
||||
|
||||
### Error Handling
|
||||
- If any tool step fails (returns `success: false`), abort and return the error
|
||||
- Optional `on_error` field per step for custom error messages
|
||||
- Timeout inherited from tool config
|
||||
|
||||
## 5. Tool Registration for Skill-Tools
|
||||
|
||||
Hardened and guided skills with a `tool_schema` field get registered in the tools array at runtime.
|
||||
|
||||
### At Skill Refresh Time
|
||||
1. Parse `tool_schema` from skill content
|
||||
2. Generate OpenAI function schema from it
|
||||
3. Append to the tools array returned by [`tools_build_openai_schema_json()`](../src/tools.c:919)
|
||||
4. When model calls the skill-tool, route to skill executor instead of hardcoded C function
|
||||
|
||||
### In [`tools_execute()`](../src/tools.c)
|
||||
1. Check if tool_name matches a hardcoded tool — execute normally
|
||||
2. If not, check if it matches a registered skill-tool
|
||||
3. If guided: run sub-LLM call with skill procedure + forced tool_choice
|
||||
4. If hardened: run deterministic step executor
|
||||
|
||||
## 6. Security Model
|
||||
|
||||
### Tool Classification
|
||||
|
||||
Tools are classified for sandbox purposes:
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
TOOL_SAFETY_SAFE, // read-only or agent-signed actions
|
||||
TOOL_SAFETY_DESTRUCTIVE // filesystem, shell, or external system mutations
|
||||
} tool_safety_t;
|
||||
```
|
||||
|
||||
| Tool | Safety | Reason |
|
||||
|------|--------|--------|
|
||||
| `nostr_query` | safe | Read-only |
|
||||
| `nostr_nip05_lookup` | safe | Read-only |
|
||||
| `nostr_post` | safe | Agent signs, admin controls keys |
|
||||
| `nostr_list_manage` | safe | Agent signs |
|
||||
| `skill_list` | safe | Read-only |
|
||||
| `skill_search` | safe | Read-only |
|
||||
| `skill_run` | safe | Recursive execution uses its own sandbox |
|
||||
| `local_shell_exec` | destructive | Arbitrary command execution |
|
||||
| `local_file_read` | destructive | Filesystem access |
|
||||
| `local_file_write` | destructive | Filesystem mutation |
|
||||
|
||||
### Sandbox Rules
|
||||
|
||||
| Scenario | Default sandbox | Override |
|
||||
|----------|----------------|---------|
|
||||
| Own adopted skill via `/run d_tag` | No sandbox | N/A |
|
||||
| External skill via `/run address` | Sandbox ON | `/run --unsafe address` |
|
||||
| `skill_run` tool, own skill | No sandbox | `sandbox: true` |
|
||||
| `skill_run` tool, external skill | Sandbox ON | `sandbox: false` |
|
||||
| Hardened skill steps | No sandbox (steps are explicit) | N/A |
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **Skill sharing:** Hardened skill-tools could be shared between agents via Nostr (kind 31123 events). Another agent adopts the skill and gets the tool automatically.
|
||||
- **Versioning:** Skills already use addressable events (d-tag). Updating a skill automatically updates the tool.
|
||||
- **Permissions:** Skill-tools could have their own permission model (e.g., some skill-tools available to WoT contacts).
|
||||
- **Composability:** Skill-tools calling other skill-tools (nested execution with sandbox inheritance).
|
||||
- **Dry-run mode:** A future `/run --dry` flag that shows what tools would be called without executing them.
|
||||
- **Skill ratings:** Agents could publish ratings/reviews of skills they've tried, building a WoT-based skill marketplace.
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
1. **Slash commands** (direct tool execution) — simplest, highest immediate value
|
||||
2. **`/run` for own adopted skills** — skill-forward execution of already-adopted skills
|
||||
3. **`skill_run` tool + external skill fetching** — enables "try my friend's skill" flow
|
||||
4. **External skill sandbox** — tool safety classification and sandbox enforcement
|
||||
5. **Hardened skill-tool step executor** — enables deterministic workflows
|
||||
6. **Skill-tool registration in tools array** — makes skill-tools visible to LLM
|
||||
7. **Guided execution with forced tool_choice** — bridges draft and hardened
|
||||
8. **Promotion workflow UX** — admin commands to change skill maturity level
|
||||
@@ -21,7 +21,7 @@ Create `src/trigger_manager.c` and `src/trigger_manager.h` — the core componen
|
||||
#define TRIGGER_COOLDOWN_SECONDS 60
|
||||
|
||||
typedef struct {
|
||||
char skill_slug[65];
|
||||
char skill_d_tag[65];
|
||||
char skill_content[4096]; // the action template or LLM prompt
|
||||
int action_type; // 0 = llm, 1 = template
|
||||
char filter_json[2048]; // the Nostr subscription filter
|
||||
@@ -43,11 +43,11 @@ typedef struct {
|
||||
```c
|
||||
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg);
|
||||
int trigger_manager_load_from_skills(trigger_manager_t* mgr);
|
||||
int trigger_manager_add(trigger_manager_t* mgr, const char* skill_slug,
|
||||
int trigger_manager_add(trigger_manager_t* mgr, const char* skill_d_tag,
|
||||
const char* content, const char* filter_json,
|
||||
int action_type, int enabled);
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug);
|
||||
int trigger_manager_update(trigger_manager_t* mgr, const char* skill_slug,
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_d_tag);
|
||||
int trigger_manager_update(trigger_manager_t* mgr, const char* skill_d_tag,
|
||||
const char* content, const char* filter_json,
|
||||
int action_type, int enabled);
|
||||
int trigger_manager_active_count(trigger_manager_t* mgr);
|
||||
@@ -115,7 +115,7 @@ When `skill_remove` is called for a triggered skill:
|
||||
The agent currently only handles DM-initiated conversations via `agent_on_message()`. Add a new entry point:
|
||||
|
||||
```c
|
||||
void agent_on_trigger(const char* skill_slug,
|
||||
void agent_on_trigger(const char* skill_d_tag,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# Plan: Unified Prompt Context for HTTP API and Nostr Paths
|
||||
|
||||
## Problem
|
||||
|
||||
The agent produces completely different LLM context depending on whether a message arrives via **Nostr DM** or the **HTTP API CLI chat app**.
|
||||
|
||||
### Nostr Path (working correctly)
|
||||
- `agent_on_message()` → `agent_build_admin_messages_json()` → tool loop
|
||||
- Builds **18 sections, ~8224 bytes** of context including:
|
||||
- System prompt / personality (from soul template)
|
||||
- Agent identity (pubkey)
|
||||
- Sender verification (admin tier)
|
||||
- Admin context (kind 0 profile, relay list, recent posts)
|
||||
- Startup events memory
|
||||
- Adopted skills
|
||||
- DM history (decrypted from Nostr relays)
|
||||
- Current user message
|
||||
|
||||
### HTTP API Path (broken)
|
||||
- CLI sends `{messages: [{role: "user", content: "Hello"}]}` to `/api/prompt/run`
|
||||
- `run_prompt_with_tools()` passes these raw messages directly to the LLM
|
||||
- Result: **1 section, ~35 bytes** — just the bare user message, zero agent context
|
||||
|
||||
## Solution: New `POST /api/prompt/agent` Endpoint
|
||||
|
||||
Add a new endpoint that mirrors the Nostr path's context assembly, so the CLI gets the same full agent context.
|
||||
|
||||
### Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Nostr DM arrives] --> B[agent_on_message]
|
||||
B --> C[agent_build_admin_messages_json]
|
||||
C --> D[Append user message]
|
||||
D --> E[Tool loop with llm_chat_with_tools_messages]
|
||||
E --> F[Send DM reply]
|
||||
|
||||
G[CLI sends POST /api/prompt/agent] --> H[handle_prompt_agent]
|
||||
H --> C
|
||||
C --> I[Append user message]
|
||||
I --> J[Tool loop - same as run_prompt_with_tools but with context]
|
||||
J --> K[Return JSON response]
|
||||
|
||||
style C fill:#4a9,stroke:#333,color:#fff
|
||||
```
|
||||
|
||||
Both paths share `agent_build_admin_messages_json()` as the single source of truth for context assembly.
|
||||
|
||||
### Request Format
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "What is the capital of France?",
|
||||
"model": "claude-haiku-4.5",
|
||||
"max_turns": 4
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `message` | string | yes | The user message to send to the agent |
|
||||
| `model` | string | no | Override the configured LLM model for this request |
|
||||
| `max_turns` | int | no | Max tool-use turns, default 4, max 16 |
|
||||
|
||||
### Response Format
|
||||
|
||||
Same as existing `/api/prompt/run`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"final_response": "The capital of France is Paris.",
|
||||
"turns": [...],
|
||||
"model_used": "claude-haiku-4.5",
|
||||
"total_input_tokens_estimate": 1973,
|
||||
"total_output_tokens_estimate": 12
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Add `handle_prompt_agent()` in `src/http_api.c`
|
||||
|
||||
New function that:
|
||||
1. Parses the JSON body to extract `message`, optional `model`, optional `max_turns`
|
||||
2. Applies model override if present via `maybe_model_override_begin()`
|
||||
3. Calls `agent_build_admin_messages_json(message, DIDACTYL_SENDER_ADMIN, &base_messages_json)` — same call the Nostr path uses
|
||||
4. Parses the result into a cJSON array
|
||||
5. Appends `{role: "user", content: message}` to the array — same as `agent_on_message()` does at line 1916
|
||||
6. Builds tool schema via `tools_build_openai_schema_json()`
|
||||
7. Runs the same tool loop as `run_prompt_with_tools()` but using the context-enriched messages
|
||||
8. Logs context via `agent_append_context_log("http_api_agent", "llm_chat_with_tools_messages", messages_json)`
|
||||
9. Returns the same response format as `/api/prompt/run`
|
||||
|
||||
Key reference points in existing code:
|
||||
- Context building: `agent_build_admin_messages_json()` at `src/agent.c:1737`
|
||||
- User message append: `append_simple_message()` pattern at `src/agent.c:1916`
|
||||
- Tool loop: reuse the loop logic from `run_prompt_with_tools()` at `src/http_api.c:278-345`
|
||||
- Context logging: `agent_append_context_log()` at `src/agent.c:1932`
|
||||
|
||||
### 2. Register the Route in `http_handler()`
|
||||
|
||||
Add before the existing `/api/prompt/run` route at `src/http_api.c:648`:
|
||||
|
||||
```c
|
||||
if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/prompt/agent"), NULL)) {
|
||||
handle_prompt_agent(c, hm);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Update `chat-didactyl-cli.js`
|
||||
|
||||
Change the CLI to call the new endpoint:
|
||||
|
||||
- Change `callDidactyl()` to POST to `/api/prompt/agent` instead of `/api/prompt/run`
|
||||
- Send `{message: "user text", max_turns: N}` instead of `{messages: [...], max_turns: N}`
|
||||
- The CLI no longer needs to maintain a `transcript` array for context — the server handles DM history from Nostr relays
|
||||
- Keep the transcript for local display purposes only
|
||||
|
||||
### 4. Context Logging Parity
|
||||
|
||||
Use a distinct but parallel phase label:
|
||||
- Nostr path: `llm_chat_with_tools_messages` (existing)
|
||||
- HTTP API agent path: `llm_chat_with_tools_messages_agent_api` (new)
|
||||
- HTTP API raw path: `llm_chat_with_tools_messages_http_api` (existing, unchanged)
|
||||
|
||||
This lets you distinguish the source in `context.log.md` while confirming the context structure is identical.
|
||||
|
||||
### 5. Update `docs/API.md`
|
||||
|
||||
Add documentation for the new `POST /api/prompt/agent` endpoint following the existing documentation style.
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/http_api.c` | Add `handle_prompt_agent()` function and route registration |
|
||||
| `chat-didactyl-cli.js` | Switch to `/api/prompt/agent`, simplify payload |
|
||||
| `docs/API.md` | Document new endpoint |
|
||||
|
||||
## What Stays the Same
|
||||
|
||||
- `/api/prompt/run` — unchanged, still accepts raw message arrays for custom/advanced use
|
||||
- `/api/prompt/run-simple` — unchanged
|
||||
- `/api/context/current` and `/api/context/parts` — unchanged
|
||||
- `agent_build_admin_messages_json()` — unchanged, already does exactly what we need
|
||||
- Nostr message handling — unchanged
|
||||
|
||||
## Remaining Consideration: Conversation History
|
||||
|
||||
The Nostr path gets DM history by querying encrypted kind-4 events from relays. The new `/api/prompt/agent` endpoint will include this same history since it calls `agent_build_admin_messages_json()`. This means:
|
||||
|
||||
- Messages sent via the CLI will NOT appear in the Nostr DM history (they are not published as Nostr events)
|
||||
- Messages sent via Nostr WILL appear in the context when using the CLI
|
||||
- This is acceptable — the CLI is a development/admin tool that piggybacks on the agent's full context
|
||||
|
||||
If in the future you want CLI messages to also appear in history, that would require either publishing them as Nostr DMs or maintaining a separate local history store — but that is out of scope for this change.
|
||||
+1226
-423
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -10,7 +10,7 @@ struct trigger_manager;
|
||||
|
||||
int agent_init(didactyl_config_t* config, const char* system_context);
|
||||
void agent_set_trigger_manager(struct trigger_manager* trigger_manager);
|
||||
void agent_on_trigger(const char* skill_slug,
|
||||
void agent_on_trigger(const char* skill_d_tag,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url);
|
||||
@@ -18,9 +18,12 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
const char* message,
|
||||
didactyl_sender_tier_t tier,
|
||||
void* user_data);
|
||||
int agent_build_admin_messages_json(const char* current_user_message, char** out_messages_json);
|
||||
int agent_build_admin_messages_json(const char* current_user_message,
|
||||
didactyl_sender_tier_t sender_tier,
|
||||
char** out_messages_json);
|
||||
tools_context_t* agent_tools_context(void);
|
||||
const char* agent_classify_message_part(cJSON* msg, int idx);
|
||||
void agent_append_context_log(const char* sender_pubkey_hex, const char* phase, const char* context_payload);
|
||||
void agent_cleanup(void);
|
||||
|
||||
#endif
|
||||
+133
-12
@@ -12,6 +12,88 @@
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* jsonc_strip_comments – strip JSONC comments from a buffer
|
||||
*
|
||||
* Returns a newly malloc'd string containing valid JSON (caller must free).
|
||||
* The function is careful to skip over JSON string literals so that comment
|
||||
* characters inside strings are preserved. Handles:
|
||||
* - single-line comments // (to end of line)
|
||||
* - block comments (slash-star ... star-slash, may span lines)
|
||||
* - escaped quotes inside strings \"
|
||||
* - trailing commas before ] or } are NOT removed (cJSON tolerates them)
|
||||
*
|
||||
* Returns NULL on allocation failure.
|
||||
* ------------------------------------------------------------------------ */
|
||||
char* jsonc_strip_comments(const char* src, size_t src_len) {
|
||||
if (!src || src_len == 0) {
|
||||
char* empty = (char*)malloc(1);
|
||||
if (empty) empty[0] = '\0';
|
||||
return empty;
|
||||
}
|
||||
|
||||
char* out = (char*)malloc(src_len + 1);
|
||||
if (!out) return NULL;
|
||||
|
||||
size_t o = 0;
|
||||
size_t i = 0;
|
||||
|
||||
while (i < src_len) {
|
||||
/* Inside a JSON string literal — copy verbatim until closing quote */
|
||||
if (src[i] == '"') {
|
||||
out[o++] = src[i++]; /* opening quote */
|
||||
while (i < src_len) {
|
||||
if (src[i] == '\\' && i + 1 < src_len) {
|
||||
out[o++] = src[i++]; /* backslash */
|
||||
out[o++] = src[i++]; /* escaped char */
|
||||
} else if (src[i] == '"') {
|
||||
out[o++] = src[i++]; /* closing quote */
|
||||
break;
|
||||
} else {
|
||||
out[o++] = src[i++];
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Single-line comment // */
|
||||
if (src[i] == '/' && i + 1 < src_len && src[i + 1] == '/') {
|
||||
/* skip to end of line */
|
||||
i += 2;
|
||||
while (i < src_len && src[i] != '\n') {
|
||||
i++;
|
||||
}
|
||||
/* preserve the newline so line numbers stay meaningful */
|
||||
if (i < src_len) {
|
||||
out[o++] = src[i++]; /* the \n */
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Block comment (slash-star ... star-slash) */
|
||||
if (src[i] == '/' && i + 1 < src_len && src[i + 1] == '*') {
|
||||
i += 2;
|
||||
while (i + 1 < src_len && !(src[i] == '*' && src[i + 1] == '/')) {
|
||||
/* preserve newlines inside block comments */
|
||||
if (src[i] == '\n') {
|
||||
out[o++] = '\n';
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (i + 1 < src_len) {
|
||||
i += 2; /* skip closing */
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Normal character — copy through */
|
||||
out[o++] = src[i++];
|
||||
}
|
||||
|
||||
out[o] = '\0';
|
||||
return out;
|
||||
}
|
||||
|
||||
static char g_config_last_error[512] = {0};
|
||||
|
||||
static void config_set_error(const char* fmt, ...) {
|
||||
@@ -315,6 +397,33 @@ static int parse_triggers_config(cJSON* root, didactyl_config_t* config) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_dm_protocol_config(cJSON* root, didactyl_config_t* config) {
|
||||
if (!root || !config) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* dm_protocol = cJSON_GetObjectItemCaseSensitive(root, "dm_protocol");
|
||||
if (!dm_protocol) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!cJSON_IsString(dm_protocol) || !dm_protocol->valuestring) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (strcmp(dm_protocol->valuestring, "nip04") == 0) {
|
||||
config->dm_protocol = DM_PROTOCOL_NIP04;
|
||||
} else if (strcmp(dm_protocol->valuestring, "nip17") == 0) {
|
||||
config->dm_protocol = DM_PROTOCOL_NIP17;
|
||||
} else if (strcmp(dm_protocol->valuestring, "both") == 0) {
|
||||
config->dm_protocol = DM_PROTOCOL_BOTH;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_api_config(cJSON* root, didactyl_config_t* config) {
|
||||
cJSON* api = cJSON_GetObjectItemCaseSensitive(root, "api");
|
||||
if (!api || !cJSON_IsObject(api)) {
|
||||
@@ -439,27 +548,23 @@ static int normalize_skill_d_tag(int event_kind, cJSON* item, cJSON* tags) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char* slug = NULL;
|
||||
cJSON* slug_val = find_tag_value_string(tags, "slug");
|
||||
if (slug_val && cJSON_IsString(slug_val) && slug_val->valuestring && slug_val->valuestring[0] != '\0') {
|
||||
slug = slug_val->valuestring;
|
||||
}
|
||||
const char* d_tag = NULL;
|
||||
|
||||
if (!slug) {
|
||||
if (!d_tag) {
|
||||
cJSON* content_fields = cJSON_GetObjectItemCaseSensitive(item, "content_fields");
|
||||
if (content_fields && cJSON_IsObject(content_fields)) {
|
||||
cJSON* name = cJSON_GetObjectItemCaseSensitive(content_fields, "name");
|
||||
if (name && cJSON_IsString(name) && name->valuestring && name->valuestring[0] != '\0') {
|
||||
slug = name->valuestring;
|
||||
d_tag = name->valuestring;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
if (!d_tag) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return set_tag_value_string(tags, "d", slug);
|
||||
return set_tag_value_string(tags, "d", d_tag);
|
||||
}
|
||||
|
||||
static int parse_startup_events(cJSON* root, didactyl_config_t* config) {
|
||||
@@ -637,6 +742,8 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
|
||||
memset(config, 0, sizeof(*config));
|
||||
snprintf(config->config_path, sizeof(config->config_path), "%s", path);
|
||||
config->dm_protocol = DM_PROTOCOL_NIP04;
|
||||
|
||||
config->tools.enabled = 1;
|
||||
config->tools.max_turns = 8;
|
||||
config->tools.shell.enabled = 1;
|
||||
@@ -670,13 +777,22 @@ 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");
|
||||
|
||||
char* json_buf = NULL;
|
||||
size_t json_len = 0;
|
||||
if (read_file_to_buffer(path, &json_buf, &json_len) != 0) {
|
||||
char* raw_buf = NULL;
|
||||
size_t raw_len = 0;
|
||||
if (read_file_to_buffer(path, &raw_buf, &raw_len) != 0) {
|
||||
config_set_error("failed to read config file '%s': %s", path, strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Strip JSONC comments before parsing */
|
||||
char* json_buf = jsonc_strip_comments(raw_buf, raw_len);
|
||||
free(raw_buf);
|
||||
if (!json_buf) {
|
||||
config_set_error("failed to allocate memory for JSONC comment stripping");
|
||||
return -1;
|
||||
}
|
||||
size_t json_len = strlen(json_buf);
|
||||
|
||||
cJSON* root = cJSON_ParseWithLength(json_buf, json_len);
|
||||
free(json_buf);
|
||||
|
||||
@@ -754,6 +870,11 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
config->llm.max_tokens = (max_tokens && cJSON_IsNumber(max_tokens)) ? (int)max_tokens->valuedouble : 512;
|
||||
config->llm.temperature = (temperature && cJSON_IsNumber(temperature)) ? temperature->valuedouble : 0.7;
|
||||
|
||||
if (parse_dm_protocol_config(root, config) != 0) {
|
||||
config_set_error("invalid dm_protocol configuration (expected 'nip04', 'nip17', or 'both')");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (parse_tools_config(root, config) != 0) {
|
||||
config_set_error("invalid tools configuration");
|
||||
goto cleanup;
|
||||
|
||||
@@ -18,6 +18,12 @@ typedef struct {
|
||||
char public_key_hex[65];
|
||||
} agent_keys_t;
|
||||
|
||||
typedef enum {
|
||||
DM_PROTOCOL_NIP04 = 0,
|
||||
DM_PROTOCOL_NIP17 = 1,
|
||||
DM_PROTOCOL_BOTH = 2
|
||||
} dm_protocol_t;
|
||||
|
||||
typedef struct {
|
||||
char pubkey[65];
|
||||
} admin_config_t;
|
||||
@@ -89,6 +95,7 @@ typedef struct {
|
||||
typedef struct {
|
||||
agent_keys_t keys;
|
||||
admin_config_t admin;
|
||||
dm_protocol_t dm_protocol;
|
||||
char** relays;
|
||||
int relay_count;
|
||||
llm_config_t llm;
|
||||
@@ -106,4 +113,7 @@ int config_load(const char* path, didactyl_config_t* config);
|
||||
const char* config_last_error(void);
|
||||
void config_free(didactyl_config_t* config);
|
||||
|
||||
/* Strip JSONC single-line and block comments, returning malloc'd pure JSON. */
|
||||
char* jsonc_strip_comments(const char* src, size_t src_len);
|
||||
|
||||
#endif
|
||||
+715
-25
@@ -5,6 +5,7 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "agent.h"
|
||||
#include "llm.h"
|
||||
@@ -20,23 +21,22 @@ static http_api_context_t g_api_ctx;
|
||||
static int g_api_initialized = 0;
|
||||
static struct mg_mgr g_mgr;
|
||||
static struct mg_connection* g_listener = NULL;
|
||||
static int g_api_tls_enabled = 0;
|
||||
static struct mg_str g_tls_cert = {NULL, 0};
|
||||
static struct mg_str g_tls_key = {NULL, 0};
|
||||
|
||||
static const char* k_json_headers =
|
||||
"Content-Type: application/json\r\n"
|
||||
"Access-Control-Allow-Origin: *\r\n"
|
||||
"Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS\r\n"
|
||||
"Access-Control-Allow-Headers: Content-Type\r\n";
|
||||
"Access-Control-Allow-Headers: Content-Type\r\n"
|
||||
"Access-Control-Allow-Private-Network: true\r\n";
|
||||
|
||||
static int method_is(const struct mg_http_message* hm, const char* method) {
|
||||
size_t n = strlen(method);
|
||||
return hm && hm->method.len == n && strncmp(hm->method.buf, method, n) == 0;
|
||||
}
|
||||
|
||||
static int mgstr_eq(struct mg_str s, const char* lit) {
|
||||
size_t n = strlen(lit);
|
||||
return s.len == n && strncmp(s.buf, lit, n) == 0;
|
||||
}
|
||||
|
||||
static void reply_json(struct mg_connection* c, int status, cJSON* root) {
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
if (!out) {
|
||||
@@ -81,6 +81,96 @@ static int estimate_tokens_from_chars(int chars) {
|
||||
return (chars + 3) / 4;
|
||||
}
|
||||
|
||||
static int uri_extract_after_prefix(const struct mg_str* uri,
|
||||
const char* prefix,
|
||||
char* out,
|
||||
size_t out_sz) {
|
||||
if (!uri || !prefix || !out || out_sz == 0) {
|
||||
return -1;
|
||||
}
|
||||
out[0] = '\0';
|
||||
|
||||
size_t prefix_len = strlen(prefix);
|
||||
if (uri->len <= prefix_len || strncmp(uri->buf, prefix, prefix_len) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t rem = uri->len - prefix_len;
|
||||
if (rem == 0 || rem >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
memcpy(out, uri->buf + prefix_len, rem);
|
||||
out[rem] = '\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
static void handle_trigger_webhook(struct mg_connection* c,
|
||||
const struct mg_http_message* hm,
|
||||
const char* d_tag) {
|
||||
if (!g_api_ctx.trigger_manager) {
|
||||
reply_error(c, 503, "trigger manager unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
active_trigger_t trigger;
|
||||
if (trigger_manager_find(g_api_ctx.trigger_manager, d_tag, &trigger) != 0) {
|
||||
reply_error(c, 404, "active trigger not found for d_tag");
|
||||
return;
|
||||
}
|
||||
|
||||
if (trigger.trigger_type != TRIGGER_TYPE_WEBHOOK) {
|
||||
reply_error(c, 400, "trigger type is not webhook");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trigger.enabled) {
|
||||
reply_error(c, 400, "webhook trigger is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* payload = parse_body_json(hm);
|
||||
if (!payload || !cJSON_IsObject(payload)) {
|
||||
cJSON_Delete(payload);
|
||||
reply_error(c, 400, "invalid JSON body");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* event = cJSON_CreateObject();
|
||||
if (!event) {
|
||||
cJSON_Delete(payload);
|
||||
reply_error(c, 500, "oom");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(event, "type", "webhook");
|
||||
cJSON_AddStringToObject(event, "skill_d_tag", d_tag);
|
||||
cJSON_AddNumberToObject(event, "created_at", (double)time(NULL));
|
||||
cJSON_AddItemToObject(event, "payload", payload);
|
||||
|
||||
int fired = trigger_manager_fire(g_api_ctx.trigger_manager, d_tag, event, "webhook");
|
||||
|
||||
cJSON_Delete(event);
|
||||
|
||||
if (fired < 0) {
|
||||
reply_error(c, 404, "active trigger not found for d_tag");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
reply_error(c, 500, "oom");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "d_tag", d_tag);
|
||||
cJSON_AddBoolToObject(out, "fired", fired > 0 ? 1 : 0);
|
||||
reply_json(c, 200, out);
|
||||
cJSON_Delete(out);
|
||||
}
|
||||
|
||||
static char* maybe_model_override_begin(cJSON* body, llm_config_t* out_old_cfg, int* out_overridden) {
|
||||
if (!body || !out_old_cfg || !out_overridden) return NULL;
|
||||
*out_overridden = 0;
|
||||
@@ -118,7 +208,7 @@ static int build_context_parts_response(cJSON* out_root) {
|
||||
if (!out_root) return -1;
|
||||
|
||||
char* messages_json = NULL;
|
||||
if (agent_build_admin_messages_json("", &messages_json) != 0 || !messages_json) {
|
||||
if (agent_build_admin_messages_json("", DIDACTYL_SENDER_ADMIN, &messages_json) != 0 || !messages_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -233,29 +323,443 @@ static int append_tool_result_message_local(cJSON* messages, const char* tool_ca
|
||||
return 0;
|
||||
}
|
||||
|
||||
static cJSON* run_prompt_with_tools(cJSON* body) {
|
||||
if (!body || !cJSON_IsObject(body)) return NULL;
|
||||
static int is_space_char_local(char c) {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r';
|
||||
}
|
||||
|
||||
cJSON* messages_node = cJSON_GetObjectItemCaseSensitive(body, "messages");
|
||||
if (!messages_node || !cJSON_IsArray(messages_node)) return NULL;
|
||||
static const char* skip_spaces_local(const char* s) {
|
||||
while (s && *s && is_space_char_local(*s)) s++;
|
||||
return s ? s : "";
|
||||
}
|
||||
|
||||
cJSON* convo = cJSON_Duplicate(messages_node, 1);
|
||||
if (!convo || !cJSON_IsArray(convo)) {
|
||||
cJSON_Delete(convo);
|
||||
static char* json_error_string_local(const char* msg) {
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
cJSON_AddBoolToObject(out, "success", 0);
|
||||
cJSON_AddStringToObject(out, "error", msg ? msg : "error");
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
static char* build_slash_args_json_local(const char* raw_args) {
|
||||
const char* s = skip_spaces_local(raw_args);
|
||||
if (!s || s[0] == '\0') {
|
||||
return strdup("{}");
|
||||
}
|
||||
|
||||
cJSON* parsed = cJSON_Parse(s);
|
||||
if (parsed) {
|
||||
char* out = cJSON_PrintUnformatted(parsed);
|
||||
cJSON_Delete(parsed);
|
||||
return out;
|
||||
}
|
||||
|
||||
cJSON* obj = cJSON_CreateObject();
|
||||
if (!obj) return NULL;
|
||||
cJSON_AddStringToObject(obj, "input", s);
|
||||
char* out = cJSON_PrintUnformatted(obj);
|
||||
cJSON_Delete(obj);
|
||||
return out;
|
||||
}
|
||||
|
||||
static int append_textf_http(char** buf, size_t* cap, size_t* used, const char* fmt, ...) {
|
||||
if (!buf || !cap || !used || !fmt) return -1;
|
||||
|
||||
while (1) {
|
||||
if (*used + 128U >= *cap) {
|
||||
size_t next_cap = (*cap) * 2U;
|
||||
char* grown = (char*)realloc(*buf, next_cap);
|
||||
if (!grown) return -1;
|
||||
*buf = grown;
|
||||
*cap = next_cap;
|
||||
}
|
||||
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
int n = vsnprintf(*buf + *used, *cap - *used, fmt, ap);
|
||||
va_end(ap);
|
||||
|
||||
if (n < 0) return -1;
|
||||
if ((size_t)n < (*cap - *used)) {
|
||||
*used += (size_t)n;
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t next_cap = (*cap) * 2U + (size_t)n + 64U;
|
||||
char* grown = (char*)realloc(*buf, next_cap);
|
||||
if (!grown) return -1;
|
||||
*buf = grown;
|
||||
*cap = next_cap;
|
||||
}
|
||||
}
|
||||
|
||||
static int append_markdown_indent_http(char** buf, size_t* cap, size_t* used, int depth) {
|
||||
for (int i = 0; i < depth; i++) {
|
||||
if (append_textf_http(buf, cap, used, " ") != 0) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int append_cjson_markdown_http(char** buf, size_t* cap, size_t* used, cJSON* node, int depth) {
|
||||
if (!node) {
|
||||
return append_markdown_indent_http(buf, cap, used, depth) == 0 &&
|
||||
append_textf_http(buf, cap, used, "- null\n") == 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
if (cJSON_IsObject(node)) {
|
||||
cJSON* child = NULL;
|
||||
cJSON_ArrayForEach(child, node) {
|
||||
const char* key = child->string ? child->string : "item";
|
||||
if (cJSON_IsString(child)) {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "- **%s:** %s\n", key,
|
||||
child->valuestring ? child->valuestring : "") != 0) {
|
||||
return -1;
|
||||
}
|
||||
} else if (cJSON_IsBool(child)) {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "- **%s:** %s\n", key,
|
||||
cJSON_IsTrue(child) ? "true" : "false") != 0) {
|
||||
return -1;
|
||||
}
|
||||
} else if (cJSON_IsNumber(child)) {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "- **%s:** %g\n", key, child->valuedouble) != 0) {
|
||||
return -1;
|
||||
}
|
||||
} else if (cJSON_IsNull(child)) {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "- **%s:** null\n", key) != 0) {
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "- **%s:**\n", key) != 0) {
|
||||
return -1;
|
||||
}
|
||||
if (append_cjson_markdown_http(buf, cap, used, child, depth + 1) != 0) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (cJSON_IsArray(node)) {
|
||||
int n = cJSON_GetArraySize(node);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* item = cJSON_GetArrayItem(node, i);
|
||||
if (cJSON_IsString(item)) {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "- %s\n", item->valuestring ? item->valuestring : "") != 0) {
|
||||
return -1;
|
||||
}
|
||||
} else if (cJSON_IsBool(item)) {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "- %s\n", cJSON_IsTrue(item) ? "true" : "false") != 0) {
|
||||
return -1;
|
||||
}
|
||||
} else if (cJSON_IsNumber(item)) {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "- %g\n", item->valuedouble) != 0) {
|
||||
return -1;
|
||||
}
|
||||
} else if (cJSON_IsNull(item)) {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "- null\n") != 0) {
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "-\n") != 0) {
|
||||
return -1;
|
||||
}
|
||||
if (append_cjson_markdown_http(buf, cap, used, item, depth + 1) != 0) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (cJSON_IsString(node)) {
|
||||
return append_markdown_indent_http(buf, cap, used, depth) == 0 &&
|
||||
append_textf_http(buf, cap, used, "- %s\n", node->valuestring ? node->valuestring : "") == 0 ? 0 : -1;
|
||||
}
|
||||
if (cJSON_IsBool(node)) {
|
||||
return append_markdown_indent_http(buf, cap, used, depth) == 0 &&
|
||||
append_textf_http(buf, cap, used, "- %s\n", cJSON_IsTrue(node) ? "true" : "false") == 0 ? 0 : -1;
|
||||
}
|
||||
if (cJSON_IsNumber(node)) {
|
||||
return append_markdown_indent_http(buf, cap, used, depth) == 0 &&
|
||||
append_textf_http(buf, cap, used, "- %g\n", node->valuedouble) == 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
return append_markdown_indent_http(buf, cap, used, depth) == 0 &&
|
||||
append_textf_http(buf, cap, used, "- null\n") == 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
static char* format_result_markdown_http(const char* raw_result) {
|
||||
if (!raw_result) {
|
||||
return strdup("No output.");
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_Parse(raw_result);
|
||||
if (!root) {
|
||||
return strdup(raw_result);
|
||||
}
|
||||
|
||||
size_t cap = strlen(raw_result) * 2U + 512U;
|
||||
if (cap < 2048U) cap = 2048U;
|
||||
size_t used = 0U;
|
||||
char* out = (char*)malloc(cap);
|
||||
if (!out) {
|
||||
cJSON_Delete(root);
|
||||
return strdup(raw_result);
|
||||
}
|
||||
out[0] = '\0';
|
||||
|
||||
if (append_cjson_markdown_http(&out, &cap, &used, root, 0) != 0) {
|
||||
cJSON_Delete(root);
|
||||
free(out);
|
||||
return strdup(raw_result);
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
static char* build_slash_help_all_json_local(void) {
|
||||
size_t cap = 2048U;
|
||||
size_t used = 0U;
|
||||
char* out = (char*)malloc(cap);
|
||||
if (!out) return NULL;
|
||||
out[0] = '\0';
|
||||
|
||||
if (append_textf_http(&out, &cap, &used, "AVAILABLE TOOLS\n") != 0) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int max_turns = 4;
|
||||
cJSON* max_turns_node = cJSON_GetObjectItemCaseSensitive(body, "max_turns");
|
||||
if (max_turns_node && cJSON_IsNumber(max_turns_node)) {
|
||||
max_turns = (int)max_turns_node->valuedouble;
|
||||
if (max_turns < 1) max_turns = 1;
|
||||
if (max_turns > 16) max_turns = 16;
|
||||
char* tool_list_json = tools_execute(g_api_ctx.tools_ctx, "tool_list", "{}");
|
||||
cJSON* tool_root = tool_list_json ? cJSON_Parse(tool_list_json) : NULL;
|
||||
cJSON* tools = tool_root ? cJSON_GetObjectItemCaseSensitive(tool_root, "tools") : NULL;
|
||||
|
||||
if (!tools || !cJSON_IsArray(tools) || cJSON_GetArraySize(tools) <= 0) {
|
||||
(void)append_textf_http(&out, &cap, &used, "- (none)\n");
|
||||
} else {
|
||||
int tn = cJSON_GetArraySize(tools);
|
||||
int* used_idx = (int*)calloc((size_t)tn, sizeof(int));
|
||||
if (!used_idx) {
|
||||
cJSON_Delete(tool_root);
|
||||
free(tool_list_json);
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
for (int k = 0; k < tn; k++) {
|
||||
int best = -1;
|
||||
const char* best_name = NULL;
|
||||
|
||||
for (int i = 0; i < tn; i++) {
|
||||
if (used_idx[i]) continue;
|
||||
cJSON* row = cJSON_GetArrayItem(tools, i);
|
||||
cJSON* name = row ? cJSON_GetObjectItemCaseSensitive(row, "name") : NULL;
|
||||
const char* name_s = (name && cJSON_IsString(name) && name->valuestring) ? name->valuestring : "unknown";
|
||||
if (best < 0 || strcmp(name_s, best_name) < 0) {
|
||||
best = i;
|
||||
best_name = name_s;
|
||||
}
|
||||
}
|
||||
|
||||
if (best < 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
used_idx[best] = 1;
|
||||
cJSON* row = cJSON_GetArrayItem(tools, best);
|
||||
cJSON* name = row ? cJSON_GetObjectItemCaseSensitive(row, "name") : NULL;
|
||||
cJSON* desc = row ? cJSON_GetObjectItemCaseSensitive(row, "description") : NULL;
|
||||
const char* name_s = (name && cJSON_IsString(name) && name->valuestring) ? name->valuestring : "unknown";
|
||||
const char* desc_s = (desc && cJSON_IsString(desc) && desc->valuestring) ? desc->valuestring : "";
|
||||
if (append_textf_http(&out, &cap, &used, "- %s%s%s\n", name_s, desc_s[0] ? " — " : "", desc_s) != 0) {
|
||||
free(used_idx);
|
||||
cJSON_Delete(tool_root);
|
||||
free(tool_list_json);
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
free(used_idx);
|
||||
}
|
||||
|
||||
cJSON_Delete(tool_root);
|
||||
free(tool_list_json);
|
||||
|
||||
if (append_textf_http(&out, &cap, &used, "\nAVAILABLE SKILLS\n") != 0) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char* skill_list_json = tools_execute(g_api_ctx.tools_ctx, "skill_list", "{}");
|
||||
cJSON* skill_root = skill_list_json ? cJSON_Parse(skill_list_json) : NULL;
|
||||
cJSON* skills = skill_root ? cJSON_GetObjectItemCaseSensitive(skill_root, "skills") : NULL;
|
||||
|
||||
if (!skills || !cJSON_IsArray(skills) || cJSON_GetArraySize(skills) <= 0) {
|
||||
(void)append_textf_http(&out, &cap, &used, "- (none)\n");
|
||||
} else {
|
||||
int sn = cJSON_GetArraySize(skills);
|
||||
for (int i = 0; i < sn; i++) {
|
||||
cJSON* row = cJSON_GetArrayItem(skills, i);
|
||||
cJSON* d_tag = row ? cJSON_GetObjectItemCaseSensitive(row, "d_tag") : NULL;
|
||||
cJSON* desc = row ? cJSON_GetObjectItemCaseSensitive(row, "description") : NULL;
|
||||
const char* d_tag_s = (d_tag && cJSON_IsString(d_tag) && d_tag->valuestring) ? d_tag->valuestring : NULL;
|
||||
const char* desc_s = (desc && cJSON_IsString(desc) && desc->valuestring) ? desc->valuestring : "";
|
||||
if (!d_tag_s || d_tag_s[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
if (append_textf_http(&out, &cap, &used, "- %s%s%s\n", d_tag_s, desc_s[0] ? " — " : "", desc_s) != 0) {
|
||||
cJSON_Delete(skill_root);
|
||||
free(skill_list_json);
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(skill_root);
|
||||
free(skill_list_json);
|
||||
return out;
|
||||
}
|
||||
|
||||
static char* build_slash_help_tool_json_local(const char* tool_name) {
|
||||
if (!tool_name || tool_name[0] == '\0') {
|
||||
return json_error_string_local("missing tool name");
|
||||
}
|
||||
|
||||
char* schema_json = tools_build_openai_schema_json(g_api_ctx.tools_ctx);
|
||||
if (!schema_json) return json_error_string_local("failed to build tool schema");
|
||||
|
||||
cJSON* schema = cJSON_Parse(schema_json);
|
||||
free(schema_json);
|
||||
if (!schema || !cJSON_IsArray(schema)) {
|
||||
cJSON_Delete(schema);
|
||||
return json_error_string_local("tool schema parse failure");
|
||||
}
|
||||
|
||||
cJSON* matched_fn = NULL;
|
||||
int n = cJSON_GetArraySize(schema);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* item = cJSON_GetArrayItem(schema, i);
|
||||
cJSON* fn = item ? cJSON_GetObjectItemCaseSensitive(item, "function") : NULL;
|
||||
cJSON* name = fn ? cJSON_GetObjectItemCaseSensitive(fn, "name") : NULL;
|
||||
if (name && cJSON_IsString(name) && name->valuestring && strcmp(name->valuestring, tool_name) == 0) {
|
||||
matched_fn = fn;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched_fn) {
|
||||
cJSON_Delete(schema);
|
||||
return json_error_string_local("unknown tool");
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
cJSON_Delete(schema);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "mode", "slash_help_tool");
|
||||
cJSON_AddStringToObject(out, "tool", tool_name);
|
||||
cJSON_AddItemToObject(out, "schema", cJSON_Duplicate(matched_fn, 1));
|
||||
|
||||
char* result = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
cJSON_Delete(schema);
|
||||
return result;
|
||||
}
|
||||
|
||||
static char* execute_slash_command_http(const char* message) {
|
||||
if (!message || message[0] != '/') return NULL;
|
||||
|
||||
const char* p = message + 1;
|
||||
while (*p && !is_space_char_local(*p)) p++;
|
||||
|
||||
size_t tool_len = (size_t)(p - (message + 1));
|
||||
if (tool_len == 0 || tool_len >= 128U) {
|
||||
return strdup("{\"success\":false,\"error\":\"invalid slash command\"}");
|
||||
}
|
||||
|
||||
char tool_name[128];
|
||||
memcpy(tool_name, message + 1, tool_len);
|
||||
tool_name[tool_len] = '\0';
|
||||
|
||||
const char* raw_args = skip_spaces_local(p);
|
||||
|
||||
char* result_json = NULL;
|
||||
if (strcmp(tool_name, "help") == 0) {
|
||||
if (!raw_args || raw_args[0] == '\0') {
|
||||
result_json = build_slash_help_all_json_local();
|
||||
} else {
|
||||
char help_tool[128];
|
||||
size_t i = 0;
|
||||
while (raw_args[i] && !is_space_char_local(raw_args[i]) && i < sizeof(help_tool) - 1U) {
|
||||
help_tool[i] = raw_args[i];
|
||||
i++;
|
||||
}
|
||||
help_tool[i] = '\0';
|
||||
result_json = build_slash_help_tool_json_local(help_tool);
|
||||
}
|
||||
} else {
|
||||
char* args_json = build_slash_args_json_local(raw_args);
|
||||
if (!args_json) {
|
||||
result_json = json_error_string_local("failed to parse slash args");
|
||||
} else {
|
||||
result_json = tools_execute(g_api_ctx.tools_ctx, tool_name, args_json);
|
||||
free(args_json);
|
||||
}
|
||||
}
|
||||
|
||||
if (!result_json) {
|
||||
result_json = strdup("{\"success\":false,\"error\":\"direct tool execution failed\"}");
|
||||
}
|
||||
|
||||
char* markdown_result = format_result_markdown_http(result_json);
|
||||
const char* dm_payload = markdown_result ? markdown_result : result_json;
|
||||
|
||||
size_t log_cap = strlen(message) + strlen(result_json ? result_json : "") + strlen(dm_payload ? dm_payload : "") + 192U;
|
||||
char* log_payload = (char*)malloc(log_cap);
|
||||
if (log_payload) {
|
||||
snprintf(log_payload, log_cap, "slash=%s\nresult_json=%s\nresult_markdown=%s",
|
||||
message,
|
||||
result_json ? result_json : "",
|
||||
dm_payload ? dm_payload : "");
|
||||
agent_append_context_log("http_api_agent", "direct_tool_exec", log_payload);
|
||||
free(log_payload);
|
||||
}
|
||||
|
||||
free(result_json);
|
||||
return markdown_result ? markdown_result : strdup("Command executed with no output.");
|
||||
}
|
||||
|
||||
static cJSON* run_prompt_with_tools_convo(cJSON* convo,
|
||||
int max_turns,
|
||||
const char* log_sender,
|
||||
const char* log_phase,
|
||||
const char* tool_limit_message) {
|
||||
if (!convo || !cJSON_IsArray(convo)) return NULL;
|
||||
|
||||
if (max_turns < 1) max_turns = 1;
|
||||
if (max_turns > 16) max_turns = 16;
|
||||
|
||||
char* tools_json = tools_build_openai_schema_json(g_api_ctx.tools_ctx);
|
||||
if (!tools_json) {
|
||||
cJSON_Delete(convo);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -265,7 +769,6 @@ static cJSON* run_prompt_with_tools(cJSON* body) {
|
||||
cJSON_Delete(root);
|
||||
cJSON_Delete(turns);
|
||||
free(tools_json);
|
||||
cJSON_Delete(convo);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -275,6 +778,10 @@ static cJSON* run_prompt_with_tools(cJSON* body) {
|
||||
char* messages_json = cJSON_PrintUnformatted(convo);
|
||||
if (!messages_json) break;
|
||||
|
||||
agent_append_context_log(log_sender ? log_sender : "http_api",
|
||||
log_phase ? log_phase : "llm_chat_with_tools_messages_http_api",
|
||||
messages_json);
|
||||
|
||||
llm_response_t resp;
|
||||
int rc = llm_chat_with_tools_messages(messages_json, tools_json, "auto", &resp);
|
||||
free(messages_json);
|
||||
@@ -340,7 +847,7 @@ static cJSON* run_prompt_with_tools(cJSON* body) {
|
||||
|
||||
done:
|
||||
if (!final_response) {
|
||||
final_response = strdup("I hit my tool-use limit for this prompt run.");
|
||||
final_response = strdup(tool_limit_message ? tool_limit_message : "I hit my tool-use limit for this prompt run.");
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
@@ -365,6 +872,32 @@ done:
|
||||
|
||||
free(final_response);
|
||||
free(tools_json);
|
||||
return root;
|
||||
}
|
||||
|
||||
static cJSON* run_prompt_with_tools(cJSON* body) {
|
||||
if (!body || !cJSON_IsObject(body)) return NULL;
|
||||
|
||||
cJSON* messages_node = cJSON_GetObjectItemCaseSensitive(body, "messages");
|
||||
if (!messages_node || !cJSON_IsArray(messages_node)) return NULL;
|
||||
|
||||
cJSON* convo = cJSON_Duplicate(messages_node, 1);
|
||||
if (!convo || !cJSON_IsArray(convo)) {
|
||||
cJSON_Delete(convo);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int max_turns = 4;
|
||||
cJSON* max_turns_node = cJSON_GetObjectItemCaseSensitive(body, "max_turns");
|
||||
if (max_turns_node && cJSON_IsNumber(max_turns_node)) {
|
||||
max_turns = (int)max_turns_node->valuedouble;
|
||||
}
|
||||
|
||||
cJSON* root = run_prompt_with_tools_convo(convo,
|
||||
max_turns,
|
||||
"http_api",
|
||||
"llm_chat_with_tools_messages_http_api",
|
||||
"I hit my tool-use limit for this prompt run.");
|
||||
cJSON_Delete(convo);
|
||||
return root;
|
||||
}
|
||||
@@ -396,7 +929,7 @@ static void handle_status(struct mg_connection* c) {
|
||||
|
||||
static void handle_context_current(struct mg_connection* c) {
|
||||
char* messages_json = NULL;
|
||||
if (agent_build_admin_messages_json("", &messages_json) != 0 || !messages_json) {
|
||||
if (agent_build_admin_messages_json("", DIDACTYL_SENDER_ADMIN, &messages_json) != 0 || !messages_json) {
|
||||
reply_error(c, 500, "failed to build context messages");
|
||||
return;
|
||||
}
|
||||
@@ -531,6 +1064,115 @@ static void handle_prompt_run(struct mg_connection* c, const struct mg_http_mess
|
||||
cJSON_Delete(result);
|
||||
}
|
||||
|
||||
static void handle_prompt_agent(struct mg_connection* c, const struct mg_http_message* hm) {
|
||||
cJSON* body = parse_body_json(hm);
|
||||
if (!body || !cJSON_IsObject(body)) {
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 400, "invalid JSON body");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* message = cJSON_GetObjectItemCaseSensitive(body, "message");
|
||||
if (!message || !cJSON_IsString(message) || !message->valuestring) {
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 400, "message string is required");
|
||||
return;
|
||||
}
|
||||
|
||||
char* slash_result = execute_slash_command_http(message->valuestring);
|
||||
if (slash_result) {
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
cJSON* turns = cJSON_CreateArray();
|
||||
if (!out || !turns) {
|
||||
free(slash_result);
|
||||
cJSON_Delete(out);
|
||||
cJSON_Delete(turns);
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 500, "oom");
|
||||
return;
|
||||
}
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "final_response", slash_result);
|
||||
cJSON_AddItemToObject(out, "turns", turns);
|
||||
cJSON_AddNumberToObject(out, "total_input_tokens_estimate", estimate_tokens_from_chars((int)strlen(message->valuestring)));
|
||||
cJSON_AddNumberToObject(out, "total_output_tokens_estimate", estimate_tokens_from_chars((int)strlen(slash_result)));
|
||||
free(slash_result);
|
||||
cJSON_Delete(body);
|
||||
reply_json(c, 200, out);
|
||||
cJSON_Delete(out);
|
||||
return;
|
||||
}
|
||||
|
||||
int max_turns = 4;
|
||||
cJSON* max_turns_node = cJSON_GetObjectItemCaseSensitive(body, "max_turns");
|
||||
if (max_turns_node && cJSON_IsNumber(max_turns_node)) {
|
||||
max_turns = (int)max_turns_node->valuedouble;
|
||||
}
|
||||
|
||||
llm_config_t old_cfg;
|
||||
int overridden = 0;
|
||||
char* model_err = maybe_model_override_begin(body, &old_cfg, &overridden);
|
||||
if (model_err) {
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 400, model_err);
|
||||
return;
|
||||
}
|
||||
|
||||
char* base_messages_json = NULL;
|
||||
cJSON* convo = NULL;
|
||||
cJSON* result = NULL;
|
||||
|
||||
if (agent_build_admin_messages_json(message->valuestring,
|
||||
DIDACTYL_SENDER_ADMIN,
|
||||
&base_messages_json) != 0 || !base_messages_json) {
|
||||
maybe_model_override_end(&old_cfg, overridden);
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 500, "failed to build agent context");
|
||||
return;
|
||||
}
|
||||
|
||||
convo = cJSON_Parse(base_messages_json);
|
||||
free(base_messages_json);
|
||||
if (!convo || !cJSON_IsArray(convo)) {
|
||||
cJSON_Delete(convo);
|
||||
maybe_model_override_end(&old_cfg, overridden);
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 500, "failed to initialize conversation messages");
|
||||
return;
|
||||
}
|
||||
|
||||
if (append_simple_message_local(convo, "user", message->valuestring) != 0) {
|
||||
cJSON_Delete(convo);
|
||||
maybe_model_override_end(&old_cfg, overridden);
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 500, "failed to append user message");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* live_user_msg = cJSON_GetArrayItem(convo, cJSON_GetArraySize(convo) - 1);
|
||||
if (live_user_msg && cJSON_IsObject(live_user_msg)) {
|
||||
cJSON_AddNumberToObject(live_user_msg, "_ts", (double)time(NULL));
|
||||
}
|
||||
|
||||
result = run_prompt_with_tools_convo(convo,
|
||||
max_turns,
|
||||
"http_api_agent",
|
||||
"llm_chat_with_tools_messages_agent_api",
|
||||
"I hit my tool-use limit for this request.");
|
||||
cJSON_Delete(convo);
|
||||
|
||||
maybe_model_override_end(&old_cfg, overridden);
|
||||
cJSON_Delete(body);
|
||||
|
||||
if (!result) {
|
||||
reply_error(c, 500, "failed to run prompt");
|
||||
return;
|
||||
}
|
||||
|
||||
reply_json(c, 200, result);
|
||||
cJSON_Delete(result);
|
||||
}
|
||||
|
||||
static cJSON* run_variant(cJSON* variant) {
|
||||
if (!variant || !cJSON_IsObject(variant)) return NULL;
|
||||
cJSON* body_copy = cJSON_Duplicate(variant, 1);
|
||||
@@ -601,6 +1243,17 @@ static void handle_prompt_compare(struct mg_connection* c, const struct mg_http_
|
||||
}
|
||||
|
||||
static void http_handler(struct mg_connection* c, int ev, void* ev_data) {
|
||||
if (ev == MG_EV_ACCEPT) {
|
||||
if (g_api_tls_enabled) {
|
||||
struct mg_tls_opts opts;
|
||||
memset(&opts, 0, sizeof(opts));
|
||||
opts.cert = g_tls_cert;
|
||||
opts.key = g_tls_key;
|
||||
mg_tls_init(c, &opts);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev != MG_EV_HTTP_MSG) {
|
||||
return;
|
||||
}
|
||||
@@ -632,10 +1285,23 @@ static void http_handler(struct mg_connection* c, int ev, void* ev_data) {
|
||||
handle_prompt_run(c, hm);
|
||||
return;
|
||||
}
|
||||
if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/prompt/agent"), NULL)) {
|
||||
handle_prompt_agent(c, hm);
|
||||
return;
|
||||
}
|
||||
if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/prompt/compare"), NULL)) {
|
||||
handle_prompt_compare(c, hm);
|
||||
return;
|
||||
}
|
||||
if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/trigger/*"), NULL)) {
|
||||
char d_tag[TRIGGER_SKILL_D_TAG_MAX] = {0};
|
||||
if (uri_extract_after_prefix(&hm->uri, "/api/trigger/", d_tag, sizeof(d_tag)) != 0) {
|
||||
reply_error(c, 400, "missing or invalid d_tag");
|
||||
return;
|
||||
}
|
||||
handle_trigger_webhook(c, hm, d_tag);
|
||||
return;
|
||||
}
|
||||
|
||||
reply_error(c, 404, "not found");
|
||||
}
|
||||
@@ -649,10 +1315,25 @@ int http_api_init(const http_api_context_t* ctx) {
|
||||
|
||||
mg_mgr_init(&g_mgr);
|
||||
|
||||
g_api_tls_enabled = 0;
|
||||
g_tls_cert = mg_file_read(&mg_fs_posix, "/home/teknari/.ssl_for_local_servers/cert.pem");
|
||||
g_tls_key = mg_file_read(&mg_fs_posix, "/home/teknari/.ssl_for_local_servers/key.pem");
|
||||
if (g_tls_cert.buf && g_tls_cert.len > 0 && g_tls_key.buf && g_tls_key.len > 0) {
|
||||
g_api_tls_enabled = 1;
|
||||
} else {
|
||||
if (g_tls_cert.buf) mg_free((void*)g_tls_cert.buf);
|
||||
if (g_tls_key.buf) mg_free((void*)g_tls_key.buf);
|
||||
g_tls_cert.buf = NULL;
|
||||
g_tls_cert.len = 0;
|
||||
g_tls_key.buf = NULL;
|
||||
g_tls_key.len = 0;
|
||||
}
|
||||
|
||||
char listen_url[320];
|
||||
snprintf(listen_url,
|
||||
sizeof(listen_url),
|
||||
"http://%s:%d",
|
||||
"%s://%s:%d",
|
||||
g_api_tls_enabled ? "https" : "http",
|
||||
g_api_ctx.cfg->api.bind_address[0] ? g_api_ctx.cfg->api.bind_address : "127.0.0.1",
|
||||
g_api_ctx.cfg->api.port > 0 ? g_api_ctx.cfg->api.port : 8484);
|
||||
|
||||
@@ -686,6 +1367,15 @@ void http_api_cleanup(void) {
|
||||
mg_mgr_free(&g_mgr);
|
||||
memset(&g_mgr, 0, sizeof(g_mgr));
|
||||
g_listener = NULL;
|
||||
|
||||
if (g_tls_cert.buf) mg_free((void*)g_tls_cert.buf);
|
||||
if (g_tls_key.buf) mg_free((void*)g_tls_key.buf);
|
||||
g_tls_cert.buf = NULL;
|
||||
g_tls_cert.len = 0;
|
||||
g_tls_key.buf = NULL;
|
||||
g_tls_key.len = 0;
|
||||
g_api_tls_enabled = 0;
|
||||
|
||||
memset(&g_api_ctx, 0, sizeof(g_api_ctx));
|
||||
g_api_initialized = 0;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "llm.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <ctype.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -64,6 +65,25 @@ static const char* detect_ca_bundle_path(void) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int url_looks_like_websocket(const char* url) {
|
||||
if (!url) return 0;
|
||||
return (strncmp(url, "ws://", 5) == 0) || (strncmp(url, "wss://", 6) == 0);
|
||||
}
|
||||
|
||||
static int json_string_is_blank(const cJSON* item) {
|
||||
if (!item || !cJSON_IsString(item) || !item->valuestring) {
|
||||
return 0;
|
||||
}
|
||||
const unsigned char* p = (const unsigned char*)item->valuestring;
|
||||
while (*p) {
|
||||
if (!isspace(*p)) {
|
||||
return 0;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static char* perform_http_request(const char* url, const char* body, int is_post) {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl || !url) {
|
||||
@@ -71,6 +91,16 @@ static char* perform_http_request(const char* url, const char* body, int is_post
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (url_looks_like_websocket(url)) {
|
||||
fprintf(stderr,
|
||||
"[didactyl] llm config error: base_url must be HTTP(S), got WebSocket URL: %s\n",
|
||||
url);
|
||||
fprintf(stderr,
|
||||
"[didactyl] llm hint: set llm.base_url to an OpenAI-compatible HTTPS endpoint, e.g. https://api.example.com/v1\n");
|
||||
curl_easy_cleanup(curl);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
|
||||
response_buffer_t rb = {0};
|
||||
struct curl_slist* headers = NULL;
|
||||
@@ -96,6 +126,19 @@ static char* perform_http_request(const char* url, const char* body, int is_post
|
||||
curl_easy_setopt(curl, CURLOPT_CAINFO, ca_bundle);
|
||||
}
|
||||
|
||||
if (is_post) {
|
||||
size_t body_len = body ? strlen(body) : 0U;
|
||||
size_t body_preview_len = body_len > 4000U ? 4000U : body_len;
|
||||
fprintf(stderr,
|
||||
"[didactyl] llm request: method=POST url=%s body_bytes=%zu body_preview=%.4000s%s\n",
|
||||
url,
|
||||
body_len,
|
||||
body ? body : "",
|
||||
body_len > body_preview_len ? "..." : "");
|
||||
} else {
|
||||
fprintf(stderr, "[didactyl] llm request: method=GET url=%s\n", url);
|
||||
}
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
long status = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
|
||||
@@ -116,6 +159,10 @@ static char* perform_http_request(const char* url, const char* body, int is_post
|
||||
|
||||
if (status < 200 || status >= 300) {
|
||||
fprintf(stderr, "[didactyl] llm http request failed: status=%ld\n", status);
|
||||
if (status == 101) {
|
||||
fprintf(stderr,
|
||||
"[didactyl] llm hint: received HTTP 101 (Switching Protocols), which usually means llm.base_url points to a WebSocket server instead of an HTTP LLM API\n");
|
||||
}
|
||||
if (rb.data && rb.len > 0) {
|
||||
fprintf(stderr, "[didactyl] llm error response: %.1200s%s\n",
|
||||
rb.data,
|
||||
@@ -321,6 +368,36 @@ int llm_chat_with_tools_messages(const char* messages_json,
|
||||
cJSON_Delete(root);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int filtered_count = 0;
|
||||
for (int i = cJSON_GetArraySize(messages) - 1; i >= 0; i--) {
|
||||
cJSON* msg = cJSON_GetArrayItem(messages, i);
|
||||
if (!msg || !cJSON_IsObject(msg)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* role = cJSON_GetObjectItemCaseSensitive(msg, "role");
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(msg, "content");
|
||||
cJSON* tool_calls = cJSON_GetObjectItemCaseSensitive(msg, "tool_calls");
|
||||
|
||||
int role_is_textual = (role && cJSON_IsString(role) && role->valuestring &&
|
||||
(strcmp(role->valuestring, "system") == 0 ||
|
||||
strcmp(role->valuestring, "user") == 0 ||
|
||||
strcmp(role->valuestring, "assistant") == 0));
|
||||
int has_tool_calls = (tool_calls && cJSON_IsArray(tool_calls) && cJSON_GetArraySize(tool_calls) > 0);
|
||||
|
||||
if (role_is_textual && !has_tool_calls && json_string_is_blank(content)) {
|
||||
cJSON_DeleteItemFromArray(messages, i);
|
||||
filtered_count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (filtered_count > 0) {
|
||||
fprintf(stderr,
|
||||
"[didactyl] llm request sanitizer: removed %d empty text message(s) before provider request\n",
|
||||
filtered_count);
|
||||
}
|
||||
|
||||
cJSON_AddItemToObject(root, "messages", messages);
|
||||
|
||||
if (tools_json) {
|
||||
|
||||
+64
-5
@@ -20,6 +20,37 @@
|
||||
|
||||
static volatile sig_atomic_t g_running = 1;
|
||||
|
||||
typedef struct {
|
||||
trigger_manager_t* trigger_manager;
|
||||
int loaded_once;
|
||||
} deferred_trigger_load_ctx_t;
|
||||
|
||||
static void on_self_skill_eose_load_triggers(int event_count, void* user_data) {
|
||||
deferred_trigger_load_ctx_t* ctx = (deferred_trigger_load_ctx_t*)user_data;
|
||||
if (!ctx || !ctx->trigger_manager) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx->loaded_once) {
|
||||
DEBUG_TRACE("[didactyl] deferred trigger load skipped (already loaded once) event_count=%d", event_count);
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] deferred trigger load begin after self-skill EOSE (event_count=%d)", event_count);
|
||||
if (trigger_manager_load_from_skills(ctx->trigger_manager) != 0) {
|
||||
DEBUG_WARN("[didactyl] deferred trigger load failed after self-skill EOSE");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx->loaded_once = 1;
|
||||
|
||||
char* status = trigger_manager_status_json(ctx->trigger_manager);
|
||||
if (status) {
|
||||
DEBUG_INFO("[didactyl] deferred trigger load status: %s", status);
|
||||
free(status);
|
||||
}
|
||||
}
|
||||
|
||||
static void signal_handler(int signum) {
|
||||
(void)signum;
|
||||
g_running = 0;
|
||||
@@ -69,7 +100,7 @@ static int wait_for_connected_relays(int timeout_ms) {
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const char* config_path = "./config.json";
|
||||
const char* config_path = "./config.jsonc";
|
||||
int debug_level = DEBUG_LEVEL_TRACE;
|
||||
int dump_schemas = 0;
|
||||
const char* test_tool_name = NULL;
|
||||
@@ -231,10 +262,21 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
agent_set_trigger_manager(&trigger_manager);
|
||||
nostr_handler_set_trigger_manager(&trigger_manager);
|
||||
|
||||
if (trigger_manager_load_from_skills(&trigger_manager) != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: trigger manager load from skills failed (continuing)");
|
||||
deferred_trigger_load_ctx_t deferred_trigger_ctx = {
|
||||
.trigger_manager = &trigger_manager,
|
||||
.loaded_once = 0
|
||||
};
|
||||
nostr_handler_set_self_skill_eose_callback(on_self_skill_eose_load_triggers, &deferred_trigger_ctx);
|
||||
|
||||
DEBUG_INFO("[didactyl] startup phase: deferred trigger load will run after self-skill EOSE");
|
||||
|
||||
DEBUG_INFO("[didactyl] startup phase: startup-config trigger load begin");
|
||||
if (trigger_manager_load_from_startup_events(&trigger_manager) != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: startup-config trigger load failed (continuing)");
|
||||
}
|
||||
DEBUG_INFO("[didactyl] startup phase: startup-config trigger load end");
|
||||
|
||||
DEBUG_INFO("[didactyl] startup phase: subscribe admin context begin");
|
||||
if (nostr_handler_subscribe_admin_context() != 0) {
|
||||
@@ -242,17 +284,32 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
DEBUG_INFO("[didactyl] startup phase: subscribe admin context end");
|
||||
|
||||
DEBUG_INFO("[didactyl] startup phase: subscribe self skill cache begin");
|
||||
if (nostr_handler_subscribe_self_skills() != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: subscribe self skill cache failed (continuing)");
|
||||
}
|
||||
DEBUG_INFO("[didactyl] startup phase: subscribe self skill cache end");
|
||||
|
||||
char startup_dm[768];
|
||||
const char* startup_name = nostr_handler_get_startup_display_name();
|
||||
int connected_relays = nostr_handler_connected_relay_count();
|
||||
time_t startup_now = time(NULL);
|
||||
struct tm startup_tm;
|
||||
char startup_time_str[32] = {0};
|
||||
if (localtime_r(&startup_now, &startup_tm)) {
|
||||
(void)strftime(startup_time_str, sizeof(startup_time_str), "%Y-%m-%d %H:%M:%S", &startup_tm);
|
||||
} else {
|
||||
snprintf(startup_time_str, sizeof(startup_time_str), "%ld", (long)startup_now);
|
||||
}
|
||||
snprintf(startup_dm,
|
||||
sizeof(startup_dm),
|
||||
"%s has started up and is online (version %s, connected relays: %d/%d).",
|
||||
"%s has started up and is online at %s (version %s, connected relays: %d/%d).",
|
||||
(startup_name && startup_name[0] != '\0') ? startup_name : "Didactyl",
|
||||
startup_time_str,
|
||||
DIDACTYL_VERSION,
|
||||
connected_relays,
|
||||
cfg.relay_count);
|
||||
if (nostr_handler_send_dm(cfg.admin.pubkey, startup_dm) != 0) {
|
||||
if (nostr_handler_send_dm_auto(cfg.admin.pubkey, startup_dm) != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: failed to send startup status DM to admin");
|
||||
}
|
||||
|
||||
@@ -309,6 +366,8 @@ int main(int argc, char** argv) {
|
||||
DEBUG_INFO("[didactyl] HTTP API disabled (set api.enabled=true in config to enable)");
|
||||
}
|
||||
|
||||
nostr_handler_refresh_relay_statuses();
|
||||
|
||||
DEBUG_INFO("[didactyl] entering main poll loop");
|
||||
DEBUG_INFO("[didactyl] running with pubkey %s", cfg.keys.public_key_hex);
|
||||
|
||||
|
||||
+2
-2
@@ -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 29
|
||||
#define DIDACTYL_VERSION "v0.0.29"
|
||||
#define DIDACTYL_VERSION_PATCH 63
|
||||
#define DIDACTYL_VERSION "v0.0.63"
|
||||
|
||||
// Agent metadata
|
||||
#define DIDACTYL_NAME "Didactyl"
|
||||
|
||||
+3
-3
@@ -14553,7 +14553,7 @@ static int mg_rsa_parse_der_int(const uint8_t **p, const uint8_t *end,
|
||||
// }
|
||||
static int mg_parse_ec_private_key(const uint8_t *der, size_t dersz,
|
||||
uint8_t *ec_key) {
|
||||
struct mg_der_tlv root, version, private_key_octets;
|
||||
struct mg_der_tlv root = {0}, version = {0}, private_key_octets = {0};
|
||||
|
||||
if (mg_der_parse((uint8_t *) der, dersz, &root) < 0 || root.type != 0x30) {
|
||||
MG_ERROR(("EC private key: invalid SEQUENCE"));
|
||||
@@ -14725,8 +14725,8 @@ static int mg_rsa_parse_key(const uint8_t *der, size_t dersz,
|
||||
// }
|
||||
static int mg_parse_pkcs8_key(const uint8_t *der, size_t dersz,
|
||||
struct tls_data *tls) {
|
||||
struct mg_der_tlv root, version, alg_id, private_key_octets;
|
||||
struct mg_der_tlv alg_oid, alg_params;
|
||||
struct mg_der_tlv root = {0}, version = {0}, alg_id = {0}, private_key_octets = {0};
|
||||
struct mg_der_tlv alg_oid = {0}, alg_params = {0};
|
||||
|
||||
if (mg_der_parse((uint8_t *) der, dersz, &root) < 0 || root.type != 0x30) {
|
||||
MG_ERROR(("PKCS#8: invalid PrivateKeyInfo SEQUENCE"));
|
||||
|
||||
+1082
-230
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,9 @@
|
||||
|
||||
#include "config.h"
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
typedef enum {
|
||||
DIDACTYL_SENDER_ADMIN = 1,
|
||||
@@ -27,25 +30,47 @@ typedef struct {
|
||||
char** relays;
|
||||
} nostr_publish_result_t;
|
||||
|
||||
typedef void (*nostr_self_skill_eose_cb_t)(int event_count, void* user_data);
|
||||
|
||||
int nostr_handler_init(didactyl_config_t* config);
|
||||
void nostr_handler_set_trigger_manager(struct trigger_manager* trigger_manager);
|
||||
int nostr_handler_subscribe_admin_context(void);
|
||||
int nostr_handler_subscribe_self_skills(void);
|
||||
char* nostr_handler_get_self_events_by_kind_json(int kind);
|
||||
void nostr_handler_set_self_skill_eose_callback(nostr_self_skill_eose_cb_t callback, void* user_data);
|
||||
int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data);
|
||||
int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message);
|
||||
int nostr_handler_send_dm_auto(const char* recipient_pubkey_hex, const char* message);
|
||||
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags, nostr_publish_result_t* out_result);
|
||||
void nostr_handler_publish_result_free(nostr_publish_result_t* result);
|
||||
char* nostr_handler_query_json(cJSON* filter, int timeout_ms);
|
||||
nostr_pool_subscription_t* nostr_handler_subscribe_with_filter(
|
||||
cJSON* filter,
|
||||
void (*on_event)(cJSON* event, const char* relay_url, void* user_data),
|
||||
void (*on_eose)(cJSON** events, int event_count, void* user_data),
|
||||
void* user_data,
|
||||
int close_on_eose,
|
||||
int enable_deduplication,
|
||||
nostr_pool_eose_result_mode_t result_mode,
|
||||
int relay_timeout_seconds,
|
||||
int eose_timeout_seconds);
|
||||
int nostr_handler_close_subscription(nostr_pool_subscription_t* subscription);
|
||||
int nostr_handler_poll(int timeout_ms);
|
||||
int nostr_handler_reconcile_startup_events(void);
|
||||
void nostr_handler_refresh_relay_statuses(void);
|
||||
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);
|
||||
int nostr_handler_connected_relay_count(void);
|
||||
char* nostr_handler_get_admin_kind0_context(void);
|
||||
char* nostr_handler_get_admin_kind3_context(void);
|
||||
char* nostr_handler_get_admin_kind10002_context(void);
|
||||
char* nostr_handler_get_admin_kind1_notes_context(void);
|
||||
int nostr_handler_is_wot_contact(const char* pubkey_hex);
|
||||
char* nostr_handler_relay_status_json(void);
|
||||
char* nostr_handler_relay_info_json(const char* relay_url);
|
||||
int nostr_handler_send_dm_nip17(const char* recipient_pubkey_hex, const char* message, const char* subject);
|
||||
char* nostr_handler_get_dm_history_json(const char* peer_pubkey_hex, int limit);
|
||||
void nostr_handler_cleanup(void);
|
||||
|
||||
#endif
|
||||
+119
-83
@@ -97,88 +97,17 @@ static int append_text(char** buf, size_t* cap, size_t* used, const char* s) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static char* resolve_placeholders(const char* tpl,
|
||||
prompt_var_resolver_fn resolver_fn,
|
||||
void* resolver_user_data) {
|
||||
if (!tpl) return strdup("");
|
||||
|
||||
size_t cap = strlen(tpl) + 64U;
|
||||
char* out = (char*)malloc(cap ? cap : 128U);
|
||||
if (!out) return NULL;
|
||||
out[0] = '\0';
|
||||
size_t used = 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* literal = dup_range(p, (size_t)(open - p));
|
||||
if (!literal) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
int rc = append_text(&out, &cap, &used, literal);
|
||||
free(literal);
|
||||
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* name = dup_range(open + 2, (size_t)(close - (open + 2)));
|
||||
if (!name) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
char* name_trim = ltrim_inplace(name);
|
||||
rtrim_inplace(name_trim);
|
||||
|
||||
char* val = resolver_fn ? resolver_fn(name_trim, resolver_user_data) : NULL;
|
||||
if (!val) {
|
||||
val = strdup("");
|
||||
}
|
||||
|
||||
int rc = 0;
|
||||
if (val) {
|
||||
rc = append_text(&out, &cap, &used, val);
|
||||
}
|
||||
free(val);
|
||||
free(name);
|
||||
if (rc != 0) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
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));
|
||||
memset(sec->role, 0, sizeof(sec->role));
|
||||
snprintf(sec->role, sizeof(sec->role), "system");
|
||||
sec->content_template = NULL;
|
||||
sec->tool_name = NULL;
|
||||
sec->tool_args = NULL;
|
||||
sec->result_field = NULL;
|
||||
sec->limit = 0;
|
||||
sec->skip_if_empty = 0;
|
||||
sec->provider_name = NULL;
|
||||
sec->provider_content_template = NULL;
|
||||
}
|
||||
@@ -281,6 +210,46 @@ int prompt_template_parse(const char* soul_content, prompt_template_t* out_templ
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts_with(line, "skip_if_empty:")) {
|
||||
char* flag = line + strlen("skip_if_empty:");
|
||||
flag = ltrim_inplace(flag);
|
||||
rtrim_inplace(flag);
|
||||
out_template->sections[current].skip_if_empty =
|
||||
(strcmp(flag, "true") == 0 || strcmp(flag, "1") == 0) ? 1 : 0;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts_with(line, "tool:")) {
|
||||
char* val = line + strlen("tool:");
|
||||
val = ltrim_inplace(val);
|
||||
rtrim_inplace(val);
|
||||
free(out_template->sections[current].tool_name);
|
||||
out_template->sections[current].tool_name = strdup(val);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts_with(line, "args:")) {
|
||||
char* val = line + strlen("args:");
|
||||
val = ltrim_inplace(val);
|
||||
rtrim_inplace(val);
|
||||
free(out_template->sections[current].tool_args);
|
||||
out_template->sections[current].tool_args = strdup((*val) ? val : "{}");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts_with(line, "result_field:")) {
|
||||
char* val = line + strlen("result_field:");
|
||||
val = ltrim_inplace(val);
|
||||
rtrim_inplace(val);
|
||||
free(out_template->sections[current].result_field);
|
||||
out_template->sections[current].result_field = strdup(val);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts_with(line, "content:")) {
|
||||
char* val = line + strlen("content:");
|
||||
val = ltrim_inplace(val);
|
||||
@@ -307,6 +276,10 @@ int prompt_template_parse(const char* soul_content, prompt_template_t* out_templ
|
||||
if (starts_with(next_ltrim, "- section:") ||
|
||||
starts_with(next_ltrim, "role:") ||
|
||||
starts_with(next_ltrim, "limit:") ||
|
||||
starts_with(next_ltrim, "skip_if_empty:") ||
|
||||
starts_with(next_ltrim, "tool:") ||
|
||||
starts_with(next_ltrim, "args:") ||
|
||||
starts_with(next_ltrim, "result_field:") ||
|
||||
starts_with(next_ltrim, "content:") ||
|
||||
starts_with(next_ltrim, "provider:")) {
|
||||
break;
|
||||
@@ -378,6 +351,10 @@ int prompt_template_parse(const char* soul_content, prompt_template_t* out_templ
|
||||
if (starts_with(next, "- section:") ||
|
||||
starts_with(next, "role:") ||
|
||||
starts_with(next, "limit:") ||
|
||||
starts_with(next, "skip_if_empty:") ||
|
||||
starts_with(next, "tool:") ||
|
||||
starts_with(next, "args:") ||
|
||||
starts_with(next, "result_field:") ||
|
||||
starts_with(next, "content:") ||
|
||||
starts_with(next, "provider:")) {
|
||||
break;
|
||||
@@ -424,10 +401,46 @@ static int append_message_object(cJSON* messages, const char* role, const char*
|
||||
return 0;
|
||||
}
|
||||
|
||||
static char* extract_tool_result_content(const prompt_template_section_t* sec, const char* tool_result_json) {
|
||||
if (!tool_result_json) return strdup("");
|
||||
|
||||
cJSON* root = cJSON_Parse(tool_result_json);
|
||||
if (!root || !cJSON_IsObject(root)) {
|
||||
cJSON_Delete(root);
|
||||
return strdup(tool_result_json);
|
||||
}
|
||||
|
||||
cJSON* success = cJSON_GetObjectItemCaseSensitive(root, "success");
|
||||
if (success && cJSON_IsBool(success) && !cJSON_IsTrue(success)) {
|
||||
cJSON_Delete(root);
|
||||
return strdup("");
|
||||
}
|
||||
|
||||
const char* field_name = (sec && sec->result_field && sec->result_field[0] != '\0')
|
||||
? sec->result_field
|
||||
: "content";
|
||||
|
||||
cJSON* field = cJSON_GetObjectItemCaseSensitive(root, field_name);
|
||||
if (!field) {
|
||||
field = cJSON_GetObjectItemCaseSensitive(root, "content");
|
||||
}
|
||||
|
||||
char* out = NULL;
|
||||
if (field && cJSON_IsString(field) && field->valuestring) {
|
||||
out = strdup(field->valuestring);
|
||||
} else if (field) {
|
||||
out = cJSON_PrintUnformatted(field);
|
||||
} else {
|
||||
out = cJSON_PrintUnformatted(root);
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return out ? out : strdup("");
|
||||
}
|
||||
|
||||
cJSON* prompt_template_build_messages(const prompt_template_t* tmpl,
|
||||
const char* provider_name,
|
||||
prompt_var_resolver_fn resolver_fn,
|
||||
void* resolver_user_data,
|
||||
tools_context_t* tools_ctx,
|
||||
cJSON* dm_history_messages,
|
||||
int dm_history_default_limit,
|
||||
prompt_template_emit_hook_fn emit_hook,
|
||||
@@ -474,18 +487,34 @@ cJSON* prompt_template_build_messages(const prompt_template_t* tmpl,
|
||||
continue;
|
||||
}
|
||||
|
||||
const char* tpl = sec->content_template;
|
||||
if (provider_name && sec->provider_name && sec->provider_content_template &&
|
||||
strcmp(provider_name, sec->provider_name) == 0) {
|
||||
tpl = sec->provider_content_template;
|
||||
}
|
||||
char* resolved = NULL;
|
||||
|
||||
char* resolved = resolve_placeholders(tpl ? tpl : "", resolver_fn, resolver_user_data);
|
||||
if (sec->tool_name && sec->tool_name[0] != '\0' && tools_ctx) {
|
||||
const char* args_json = (sec->tool_args && sec->tool_args[0] != '\0') ? sec->tool_args : "{}";
|
||||
char* tool_result = tools_execute(tools_ctx, sec->tool_name, args_json);
|
||||
resolved = extract_tool_result_content(sec, tool_result);
|
||||
free(tool_result);
|
||||
} else {
|
||||
const char* tpl = sec->content_template;
|
||||
if (provider_name && sec->provider_name && sec->provider_content_template &&
|
||||
strcmp(provider_name, sec->provider_name) == 0) {
|
||||
tpl = sec->provider_content_template;
|
||||
}
|
||||
resolved = strdup(tpl ? tpl : "");
|
||||
}
|
||||
if (!resolved) {
|
||||
cJSON_Delete(out);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (sec->skip_if_empty) {
|
||||
char* chk = ltrim_inplace(resolved);
|
||||
if (chk && *chk == '\0') {
|
||||
free(resolved);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (append_message_object(out, role, resolved) != 0) {
|
||||
free(resolved);
|
||||
cJSON_Delete(out);
|
||||
@@ -508,6 +537,12 @@ void prompt_template_free(prompt_template_t* tmpl) {
|
||||
for (int i = 0; i < tmpl->section_count; i++) {
|
||||
free(tmpl->sections[i].content_template);
|
||||
tmpl->sections[i].content_template = NULL;
|
||||
free(tmpl->sections[i].tool_name);
|
||||
tmpl->sections[i].tool_name = NULL;
|
||||
free(tmpl->sections[i].tool_args);
|
||||
tmpl->sections[i].tool_args = NULL;
|
||||
free(tmpl->sections[i].result_field);
|
||||
tmpl->sections[i].result_field = NULL;
|
||||
free(tmpl->sections[i].provider_name);
|
||||
tmpl->sections[i].provider_name = NULL;
|
||||
free(tmpl->sections[i].provider_content_template);
|
||||
@@ -515,6 +550,7 @@ void prompt_template_free(prompt_template_t* tmpl) {
|
||||
tmpl->sections[i].name[0] = '\0';
|
||||
tmpl->sections[i].role[0] = '\0';
|
||||
tmpl->sections[i].limit = 0;
|
||||
tmpl->sections[i].skip_if_empty = 0;
|
||||
}
|
||||
|
||||
tmpl->section_count = 0;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#define DIDACTYL_PROMPT_TEMPLATE_H
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "tools.h"
|
||||
|
||||
#define PROMPT_TEMPLATE_MAX_SECTIONS 32
|
||||
#define PROMPT_TEMPLATE_MAX_NAME_LEN 64
|
||||
@@ -12,7 +13,11 @@ typedef struct {
|
||||
char name[PROMPT_TEMPLATE_MAX_NAME_LEN];
|
||||
char role[PROMPT_TEMPLATE_MAX_ROLE_LEN];
|
||||
char* content_template;
|
||||
char* tool_name;
|
||||
char* tool_args;
|
||||
char* result_field;
|
||||
int limit;
|
||||
int skip_if_empty;
|
||||
char* provider_name;
|
||||
char* provider_content_template;
|
||||
} prompt_template_section_t;
|
||||
@@ -23,7 +28,6 @@ typedef struct {
|
||||
int section_count;
|
||||
} prompt_template_t;
|
||||
|
||||
typedef char* (*prompt_var_resolver_fn)(const char* var_name, void* user_data);
|
||||
typedef void (*prompt_template_emit_hook_fn)(const char* section_name,
|
||||
int message_index,
|
||||
void* user_data);
|
||||
@@ -32,8 +36,7 @@ int prompt_template_parse(const char* soul_content, prompt_template_t* out_templ
|
||||
|
||||
cJSON* prompt_template_build_messages(const prompt_template_t* tmpl,
|
||||
const char* provider_name,
|
||||
prompt_var_resolver_fn resolver_fn,
|
||||
void* resolver_user_data,
|
||||
tools_context_t* tools_ctx,
|
||||
cJSON* dm_history_messages,
|
||||
int dm_history_default_limit,
|
||||
prompt_template_emit_hook_fn emit_hook,
|
||||
|
||||
+1416
-175
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ struct trigger_manager;
|
||||
typedef struct {
|
||||
didactyl_config_t* cfg;
|
||||
struct trigger_manager* trigger_manager;
|
||||
int template_sender_tier;
|
||||
const char* template_current_user_message;
|
||||
} tools_context_t;
|
||||
|
||||
int tools_init(tools_context_t* ctx, didactyl_config_t* cfg);
|
||||
|
||||
+689
-196
File diff suppressed because it is too large
Load Diff
+34
-5
@@ -5,9 +5,11 @@
|
||||
#include <time.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
#define TRIGGER_DEFAULT_MAX_ACTIVE 16
|
||||
#define TRIGGER_SKILL_SLUG_MAX 65
|
||||
#define TRIGGER_SKILL_D_TAG_MAX 65
|
||||
#define TRIGGER_SKILL_CONTENT_MAX 4096
|
||||
#define TRIGGER_FILTER_JSON_MAX 2048
|
||||
|
||||
@@ -16,14 +18,26 @@ typedef enum {
|
||||
TRIGGER_ACTION_TEMPLATE = 1
|
||||
} trigger_action_type_t;
|
||||
|
||||
typedef enum {
|
||||
TRIGGER_TYPE_NOSTR_SUBSCRIPTION = 0,
|
||||
TRIGGER_TYPE_WEBHOOK = 1,
|
||||
TRIGGER_TYPE_CRON = 2,
|
||||
TRIGGER_TYPE_CHAIN = 3
|
||||
} trigger_type_t;
|
||||
|
||||
typedef struct {
|
||||
char skill_slug[TRIGGER_SKILL_SLUG_MAX];
|
||||
char skill_d_tag[TRIGGER_SKILL_D_TAG_MAX];
|
||||
char skill_content[TRIGGER_SKILL_CONTENT_MAX];
|
||||
char filter_json[TRIGGER_FILTER_JSON_MAX];
|
||||
trigger_action_type_t action_type;
|
||||
int enabled;
|
||||
trigger_type_t trigger_type;
|
||||
time_t last_fired;
|
||||
time_t last_seen_created_at;
|
||||
time_t last_cron_fire;
|
||||
char cron_expr[64];
|
||||
nostr_pool_subscription_t* subscription;
|
||||
void* subscription_ctx;
|
||||
} active_trigger_t;
|
||||
|
||||
typedef struct trigger_manager {
|
||||
@@ -37,19 +51,34 @@ typedef struct trigger_manager {
|
||||
|
||||
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg);
|
||||
int trigger_manager_load_from_skills(trigger_manager_t* mgr);
|
||||
int trigger_manager_load_from_startup_events(trigger_manager_t* mgr);
|
||||
trigger_type_t trigger_type_from_string(const char* s);
|
||||
const char* trigger_type_to_string(trigger_type_t t);
|
||||
|
||||
int trigger_manager_add(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* skill_d_tag,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
const char* trigger_type_str,
|
||||
int enabled);
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug);
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_d_tag);
|
||||
int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out);
|
||||
int trigger_manager_update(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* skill_d_tag,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
const char* trigger_type_str,
|
||||
int enabled);
|
||||
int trigger_manager_fire(trigger_manager_t* mgr,
|
||||
const char* skill_d_tag,
|
||||
cJSON* event,
|
||||
const char* source_label);
|
||||
int trigger_manager_fire_chains(trigger_manager_t* mgr,
|
||||
const char* source_skill_d_tag,
|
||||
cJSON* source_event,
|
||||
const char* source_label);
|
||||
int trigger_manager_active_count(trigger_manager_t* mgr);
|
||||
int trigger_manager_poll(trigger_manager_t* mgr);
|
||||
char* trigger_manager_status_json(trigger_manager_t* mgr);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"tasks":[],"next_id":9}
|
||||
Executable
+104
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
pass() {
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
printf "[PASS] %s\n" "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
printf "[FAIL] %s\n" "$1"
|
||||
}
|
||||
|
||||
assert_grep() {
|
||||
local pattern="$1"
|
||||
local file="$2"
|
||||
local desc="$3"
|
||||
if grep -E -q "$pattern" "$file"; then
|
||||
pass "$desc"
|
||||
else
|
||||
fail "$desc (pattern not found in $file)"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_cmd() {
|
||||
local desc="$1"
|
||||
shift
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
pass "$desc"
|
||||
else
|
||||
fail "$desc"
|
||||
fi
|
||||
}
|
||||
|
||||
printf "== Didactyl Trigger Feature Test ==\n"
|
||||
printf "Root: %s\n" "$ROOT_DIR"
|
||||
|
||||
printf "\n-- Build check --\n"
|
||||
if make -j >/tmp/didactyl-test-build.log 2>&1; then
|
||||
pass "Project builds successfully"
|
||||
else
|
||||
fail "Project build failed (see /tmp/didactyl-test-build.log)"
|
||||
fi
|
||||
|
||||
assert_cmd "Built binary exists" test -x ./didactyl
|
||||
|
||||
printf "\n-- Source wiring checks --\n"
|
||||
assert_grep "TRIGGER_TYPE_WEBHOOK" src/trigger_manager.h "Webhook trigger type defined"
|
||||
assert_grep "TRIGGER_TYPE_CRON" src/trigger_manager.h "Cron trigger type defined"
|
||||
assert_grep "TRIGGER_TYPE_CHAIN" src/trigger_manager.h "Chain trigger type defined"
|
||||
|
||||
assert_grep "trigger_manager_fire\(" src/trigger_manager.c "Generic trigger fire API implemented"
|
||||
assert_grep "trigger_manager_fire_chains\(" src/trigger_manager.c "Chain fire API implemented"
|
||||
assert_grep "cron_matches_now\(" src/trigger_manager.c "Cron matcher implemented"
|
||||
assert_grep "trigger_type_to_string" src/trigger_manager.c "Trigger type serialization implemented"
|
||||
|
||||
assert_grep "\"/api/trigger/\*\"" src/http_api.c "Webhook API route registered"
|
||||
assert_grep "handle_trigger_webhook\(" src/http_api.c "Webhook handler implemented"
|
||||
|
||||
assert_grep "nostr-subscription, webhook, cron, chain" src/tools.c "skill_create validation accepts 4 trigger types"
|
||||
assert_grep "skill_edit trigger must be one of: nostr-subscription, webhook, cron, chain" src/tools.c "skill_edit validation accepts 4 trigger types"
|
||||
|
||||
assert_grep "trigger_manager_fire_chains\(" src/agent.c "Post-execution chain hook wired in agent"
|
||||
|
||||
printf "\n-- Docs checks --\n"
|
||||
assert_grep "\`webhook\`" docs/SKILLS.md "SKILLS docs include webhook trigger"
|
||||
assert_grep "\`cron\`" docs/SKILLS.md "SKILLS docs include cron trigger"
|
||||
assert_grep "\`chain\`" docs/SKILLS.md "SKILLS docs include chain trigger"
|
||||
assert_grep "POST /api/trigger/:d_tag" docs/API.md "API docs include webhook endpoint"
|
||||
|
||||
printf "\n-- Optional local API probe --\n"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
HTTP_CODE=""
|
||||
HTTP_CODE="$(curl -k -s --connect-timeout 2 --max-time 5 -o /tmp/didactyl-trigger-probe.out -w "%{http_code}" https://127.0.0.1:8484/api/trigger/nonexistent -X POST -H 'Content-Type: application/json' -d '{}' || true)"
|
||||
if [[ "$HTTP_CODE" == "000" ]]; then
|
||||
HTTP_CODE="$(curl -s --connect-timeout 2 --max-time 5 -o /tmp/didactyl-trigger-probe.out -w "%{http_code}" http://127.0.0.1:8484/api/trigger/nonexistent -X POST -H 'Content-Type: application/json' -d '{}' || true)"
|
||||
fi
|
||||
|
||||
if [[ "$HTTP_CODE" == "200" || "$HTTP_CODE" == "400" || "$HTTP_CODE" == "404" || "$HTTP_CODE" == "503" ]]; then
|
||||
pass "Webhook endpoint responds on local API (http code: $HTTP_CODE)"
|
||||
elif [[ "$HTTP_CODE" == "000" || -z "$HTTP_CODE" ]]; then
|
||||
pass "Webhook endpoint probe skipped (no local API listener detected)"
|
||||
else
|
||||
fail "Webhook endpoint probe did not get expected response (http code: ${HTTP_CODE:-none})"
|
||||
fi
|
||||
else
|
||||
fail "curl not available for API probe"
|
||||
fi
|
||||
|
||||
printf "\n== Result ==\n"
|
||||
printf "Pass: %d\n" "$PASS_COUNT"
|
||||
printf "Fail: %d\n" "$FAIL_COUNT"
|
||||
|
||||
if [[ "$FAIL_COUNT" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user