Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e910304f6f | ||
|
|
b1609317c1 | ||
|
|
4a400f1582 | ||
|
|
a2d3f840c7 | ||
|
|
7f31e4ceb7 | ||
|
|
052c11863f |
@@ -6,6 +6,11 @@
|
||||
/nips/
|
||||
/config.json
|
||||
test_keys.txt
|
||||
|
||||
/mongoose/
|
||||
|
||||
|
||||
|
||||
# Build artifacts
|
||||
/build/
|
||||
/didactyl
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
11469571 3 mongoose.c:4824:mg_mgr_init MG_IO_SIZE: 16384, TLS: none
|
||||
11469571 3 mongoose.c:4741:mg_listen 1 9 http://127.0.0.1:8586
|
||||
114695f9 3 mongoose.c:10782:accept_conn 2 10 accepted 127.0.0.1:51708 -> 127.0.0.1:8586
|
||||
11469636 3 mongoose.c:10632:read_conn 2 10 0:0:0 88 err 0
|
||||
11469674 3 mongoose.c:10643:write_conn 2 10 snd 406/16384 rcv 0/16384 n=406 err=0
|
||||
114696b1 3 mongoose.c:10632:read_conn 2 10 0:0:0 -1 err 0
|
||||
114696b2 3 mongoose.c:4677:mg_close_con 2 10 closed
|
||||
114696b2 3 mongoose.c:10782:accept_conn 3 10 accepted 127.0.0.1:51718 -> 127.0.0.1:8586
|
||||
114696dd 3 mongoose.c:10632:read_conn 3 10 0:0:0 257 err 0
|
||||
11469e00 3 mongoose.c:10643:write_conn 3 10 snd 424/16384 rcv 0/16384 n=424 err=0
|
||||
11469e3d 3 mongoose.c:10632:read_conn 3 10 0:0:0 -1 err 0
|
||||
11469e3d 3 mongoose.c:4677:mg_close_con 3 10 closed
|
||||
1146a21a 3 mongoose.c:4677:mg_close_con 1 9 closed
|
||||
1146a21a 3 mongoose.c:4788:mg_mgr_free All connections closed
|
||||
@@ -0,0 +1 @@
|
||||
[2026-03-02 14:46:38] [WARN ] [didactyl] retrying kind 4 event once after auth handshake window
|
||||
@@ -0,0 +1 @@
|
||||
1217023
|
||||
@@ -0,0 +1,14 @@
|
||||
1148b030 3 mongoose.c:4824:mg_mgr_init MG_IO_SIZE: 16384, TLS: none
|
||||
1148b030 3 mongoose.c:4741:mg_listen 1 9 http://127.0.0.1:8591
|
||||
1148b087 3 mongoose.c:10782:accept_conn 2 10 accepted 127.0.0.1:34824 -> 127.0.0.1:8591
|
||||
1148b0c7 3 mongoose.c:10632:read_conn 2 10 0:0:0 88 err 0
|
||||
1148b0eb 3 mongoose.c:10643:write_conn 2 10 snd 406/16384 rcv 0/16384 n=406 err=0
|
||||
1148b129 3 mongoose.c:10632:read_conn 2 10 0:0:0 -1 err 0
|
||||
1148b129 3 mongoose.c:4677:mg_close_con 2 10 closed
|
||||
1148b129 3 mongoose.c:10782:accept_conn 3 10 accepted 127.0.0.1:34828 -> 127.0.0.1:8591
|
||||
1148b166 3 mongoose.c:10632:read_conn 3 10 0:0:0 257 err 0
|
||||
1148b6c4 3 mongoose.c:10643:write_conn 3 10 snd 424/16384 rcv 0/16384 n=424 err=0
|
||||
1148b701 3 mongoose.c:10632:read_conn 3 10 0:0:0 -1 err 0
|
||||
1148b701 3 mongoose.c:4677:mg_close_con 3 10 closed
|
||||
1148bac5 3 mongoose.c:4677:mg_close_con 1 9 closed
|
||||
1148bac5 3 mongoose.c:4788:mg_mgr_free All connections closed
|
||||
@@ -0,0 +1,19 @@
|
||||
11494a06 3 mongoose.c:4824:mg_mgr_init MG_IO_SIZE: 16384, TLS: none
|
||||
11494a06 3 mongoose.c:4741:mg_listen 1 9 http://127.0.0.1:8592
|
||||
11494c7b 3 mongoose.c:10782:accept_conn 2 10 accepted 127.0.0.1:46110 -> 127.0.0.1:8592
|
||||
11494cb8 3 mongoose.c:10632:read_conn 2 10 0:0:0 88 err 0
|
||||
11494cf5 3 mongoose.c:10643:write_conn 2 10 snd 406/16384 rcv 0/16384 n=406 err=0
|
||||
11494d32 3 mongoose.c:10632:read_conn 2 10 0:0:0 -1 err 0
|
||||
11494d32 3 mongoose.c:4677:mg_close_con 2 10 closed
|
||||
11494d32 3 mongoose.c:10782:accept_conn 3 10 accepted 127.0.0.1:46120 -> 127.0.0.1:8592
|
||||
11494d6f 3 mongoose.c:10632:read_conn 3 10 0:0:0 97 err 0
|
||||
114973d4 3 mongoose.c:10643:write_conn 3 10 snd 13914/16384 rcv 0/16384 n=13914 err=0
|
||||
11497449 3 mongoose.c:10632:read_conn 3 10 0:0:0 -1 err 0
|
||||
11497449 3 mongoose.c:4677:mg_close_con 3 10 closed
|
||||
11497449 3 mongoose.c:10782:accept_conn 4 10 accepted 127.0.0.1:59822 -> 127.0.0.1:8592
|
||||
114974b8 3 mongoose.c:10632:read_conn 4 10 0:0:0 257 err 0
|
||||
11497ed5 3 mongoose.c:10643:write_conn 4 10 snd 424/16384 rcv 0/16384 n=424 err=0
|
||||
11497f43 3 mongoose.c:10632:read_conn 4 10 0:0:0 -1 err 11
|
||||
11497f43 3 mongoose.c:4677:mg_close_con 4 10 closed
|
||||
1149831f 3 mongoose.c:4677:mg_close_con 1 9 closed
|
||||
1149831f 3 mongoose.c:4788:mg_mgr_free All connections closed
|
||||
@@ -0,0 +1,251 @@
|
||||
{
|
||||
"keys": {
|
||||
"nsec": "nsec17pcmmqxh99rtu5ctpxrdyywdd8f7zfqgf3y2f4fvcm3tk3vpuyqqvlxl4e",
|
||||
"npub": "npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn",
|
||||
"npubHex": "52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8",
|
||||
"nsecHex": "f071bd80d72946be530b0986d211cd69d3e124084c48a4d52cc6e2bb4581e100"
|
||||
},
|
||||
"admin": {
|
||||
"pubkey": "npub13lm5wf8dvsdnc2894pkhch9uf8phvw9varrv8zf4sc885hhdmc8q6lx7ks"
|
||||
},
|
||||
"llm": {
|
||||
"provider": "https://api.ppq.ai",
|
||||
"api_key": "sk-L9kd0LkZaoeaua0qqosnHy",
|
||||
"model": "claude-haiku-4.5",
|
||||
"base_url": "https://api.ppq.ai",
|
||||
"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
|
||||
},
|
||||
"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.",
|
||||
"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": "Publish README.md using the dedicated nostr_post_readme tool",
|
||||
"uses_skill": "long_form_note",
|
||||
"event_kind": 30023,
|
||||
"procedure": [
|
||||
"Call nostr_post_readme with empty arguments {}.",
|
||||
"Return the tool response including event_id, d_tag, and naddr_uri.",
|
||||
"If the tool returns success=false, report the tool error verbatim."
|
||||
],
|
||||
"required_values": {
|
||||
"d": "readme.md",
|
||||
"tool": "nostr_post_readme"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"api": {
|
||||
"enabled": true,
|
||||
"port": 8484,
|
||||
"bind_address": "127.0.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
{
|
||||
"keys": {
|
||||
"nsec": "nsec17pcmmqxh99rtu5ctpxrdyywdd8f7zfqgf3y2f4fvcm3tk3vpuyqqvlxl4e",
|
||||
"npub": "npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn",
|
||||
"npubHex": "52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8",
|
||||
"nsecHex": "f071bd80d72946be530b0986d211cd69d3e124084c48a4d52cc6e2bb4581e100"
|
||||
},
|
||||
"admin": {
|
||||
"pubkey": "npub13lm5wf8dvsdnc2894pkhch9uf8phvw9varrv8zf4sc885hhdmc8q6lx7ks"
|
||||
},
|
||||
"llm": {
|
||||
"provider": "https://api.ppq.ai",
|
||||
"api_key": "sk-L9kd0LkZaoeaua0qqosnHy",
|
||||
"model": "claude-haiku-4.5",
|
||||
"base_url": "https://api.ppq.ai",
|
||||
"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
|
||||
},
|
||||
"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.",
|
||||
"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": "Publish README.md using the dedicated nostr_post_readme tool",
|
||||
"uses_skill": "long_form_note",
|
||||
"event_kind": 30023,
|
||||
"procedure": [
|
||||
"Call nostr_post_readme with empty arguments {}.",
|
||||
"Return the tool response including event_id, d_tag, and naddr_uri.",
|
||||
"If the tool returns success=false, report the tool error verbatim."
|
||||
],
|
||||
"required_values": {
|
||||
"d": "readme.md",
|
||||
"tool": "nostr_post_readme"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"api": {
|
||||
"enabled": true,
|
||||
"port": 8585,
|
||||
"bind_address": "127.0.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
{
|
||||
"keys": {
|
||||
"nsec": "nsec17pcmmqxh99rtu5ctpxrdyywdd8f7zfqgf3y2f4fvcm3tk3vpuyqqvlxl4e",
|
||||
"npub": "npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn",
|
||||
"npubHex": "52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8",
|
||||
"nsecHex": "f071bd80d72946be530b0986d211cd69d3e124084c48a4d52cc6e2bb4581e100"
|
||||
},
|
||||
"admin": {
|
||||
"pubkey": "npub13lm5wf8dvsdnc2894pkhch9uf8phvw9varrv8zf4sc885hhdmc8q6lx7ks"
|
||||
},
|
||||
"llm": {
|
||||
"provider": "https://api.ppq.ai",
|
||||
"api_key": "sk-L9kd0LkZaoeaua0qqosnHy",
|
||||
"model": "claude-haiku-4.5",
|
||||
"base_url": "https://api.ppq.ai",
|
||||
"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
|
||||
},
|
||||
"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.",
|
||||
"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": "Publish README.md using the dedicated nostr_post_readme tool",
|
||||
"uses_skill": "long_form_note",
|
||||
"event_kind": 30023,
|
||||
"procedure": [
|
||||
"Call nostr_post_readme with empty arguments {}.",
|
||||
"Return the tool response including event_id, d_tag, and naddr_uri.",
|
||||
"If the tool returns success=false, report the tool error verbatim."
|
||||
],
|
||||
"required_values": {
|
||||
"d": "readme.md",
|
||||
"tool": "nostr_post_readme"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"api": {
|
||||
"enabled": true,
|
||||
"port": 8586,
|
||||
"bind_address": "127.0.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
{
|
||||
"keys": {
|
||||
"nsec": "nsec17pcmmqxh99rtu5ctpxrdyywdd8f7zfqgf3y2f4fvcm3tk3vpuyqqvlxl4e",
|
||||
"npub": "npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn",
|
||||
"npubHex": "52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8",
|
||||
"nsecHex": "f071bd80d72946be530b0986d211cd69d3e124084c48a4d52cc6e2bb4581e100"
|
||||
},
|
||||
"admin": {
|
||||
"pubkey": "npub13lm5wf8dvsdnc2894pkhch9uf8phvw9varrv8zf4sc885hhdmc8q6lx7ks"
|
||||
},
|
||||
"llm": {
|
||||
"provider": "https://api.ppq.ai",
|
||||
"api_key": "sk-L9kd0LkZaoeaua0qqosnHy",
|
||||
"model": "claude-haiku-4.5",
|
||||
"base_url": "https://api.ppq.ai",
|
||||
"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
|
||||
},
|
||||
"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.",
|
||||
"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": "Publish README.md using the dedicated nostr_post_readme tool",
|
||||
"uses_skill": "long_form_note",
|
||||
"event_kind": 30023,
|
||||
"procedure": [
|
||||
"Call nostr_post_readme with empty arguments {}.",
|
||||
"Return the tool response including event_id, d_tag, and naddr_uri.",
|
||||
"If the tool returns success=false, report the tool error verbatim."
|
||||
],
|
||||
"required_values": {
|
||||
"d": "readme.md",
|
||||
"tool": "nostr_post_readme"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"api": {
|
||||
"enabled": true,
|
||||
"port": 8590,
|
||||
"bind_address": "127.0.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
{
|
||||
"keys": {
|
||||
"nsec": "nsec17pcmmqxh99rtu5ctpxrdyywdd8f7zfqgf3y2f4fvcm3tk3vpuyqqvlxl4e",
|
||||
"npub": "npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn",
|
||||
"npubHex": "52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8",
|
||||
"nsecHex": "f071bd80d72946be530b0986d211cd69d3e124084c48a4d52cc6e2bb4581e100"
|
||||
},
|
||||
"admin": {
|
||||
"pubkey": "npub13lm5wf8dvsdnc2894pkhch9uf8phvw9varrv8zf4sc885hhdmc8q6lx7ks"
|
||||
},
|
||||
"llm": {
|
||||
"provider": "https://api.ppq.ai",
|
||||
"api_key": "sk-L9kd0LkZaoeaua0qqosnHy",
|
||||
"model": "claude-haiku-4.5",
|
||||
"base_url": "https://api.ppq.ai",
|
||||
"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
|
||||
},
|
||||
"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.",
|
||||
"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": "Publish README.md using the dedicated nostr_post_readme tool",
|
||||
"uses_skill": "long_form_note",
|
||||
"event_kind": 30023,
|
||||
"procedure": [
|
||||
"Call nostr_post_readme with empty arguments {}.",
|
||||
"Return the tool response including event_id, d_tag, and naddr_uri.",
|
||||
"If the tool returns success=false, report the tool error verbatim."
|
||||
],
|
||||
"required_values": {
|
||||
"d": "readme.md",
|
||||
"tool": "nostr_post_readme"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"api": {
|
||||
"enabled": true,
|
||||
"port": 8591,
|
||||
"bind_address": "127.0.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
{
|
||||
"keys": {
|
||||
"nsec": "nsec17pcmmqxh99rtu5ctpxrdyywdd8f7zfqgf3y2f4fvcm3tk3vpuyqqvlxl4e",
|
||||
"npub": "npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn",
|
||||
"npubHex": "52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8",
|
||||
"nsecHex": "f071bd80d72946be530b0986d211cd69d3e124084c48a4d52cc6e2bb4581e100"
|
||||
},
|
||||
"admin": {
|
||||
"pubkey": "npub13lm5wf8dvsdnc2894pkhch9uf8phvw9varrv8zf4sc885hhdmc8q6lx7ks"
|
||||
},
|
||||
"llm": {
|
||||
"provider": "https://api.ppq.ai",
|
||||
"api_key": "sk-L9kd0LkZaoeaua0qqosnHy",
|
||||
"model": "claude-haiku-4.5",
|
||||
"base_url": "https://api.ppq.ai",
|
||||
"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
|
||||
},
|
||||
"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.",
|
||||
"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": "Publish README.md using the dedicated nostr_post_readme tool",
|
||||
"uses_skill": "long_form_note",
|
||||
"event_kind": 30023,
|
||||
"procedure": [
|
||||
"Call nostr_post_readme with empty arguments {}.",
|
||||
"Return the tool response including event_id, d_tag, and naddr_uri.",
|
||||
"If the tool returns success=false, report the tool error verbatim."
|
||||
],
|
||||
"required_values": {
|
||||
"d": "readme.md",
|
||||
"tool": "nostr_post_readme"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"api": {
|
||||
"enabled": true,
|
||||
"port": 8592,
|
||||
"bind_address": "127.0.0.1"
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \
|
||||
-I. -Isrc -Inostr_core_lib -Inostr_core_lib/nostr_core \
|
||||
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
|
||||
src/main.c src/config.c src/context.c src/llm.c \
|
||||
src/nostr_handler.c src/agent.c src/tools.c src/debug.c \
|
||||
src/nostr_handler.c src/agent.c src/tools.c src/trigger_manager.c src/debug.c \
|
||||
-o /build/didactyl_static \
|
||||
nostr_core_lib/libnostr_core_x64.a \
|
||||
-lsecp256k1 \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
CC = gcc
|
||||
CFLAGS = -std=c99 -Wall -Wextra -Wpedantic -O2 -D_POSIX_C_SOURCE=200809L
|
||||
CFLAGS = -std=c99 -Wall -Wextra -Wpedantic -O2 -D_GNU_SOURCE -D_DEFAULT_SOURCE -D_POSIX_C_SOURCE=200809L
|
||||
|
||||
SRC_DIR = src
|
||||
TARGET = didactyl
|
||||
@@ -12,6 +12,9 @@ SRCS = \
|
||||
$(SRC_DIR)/nostr_handler.c \
|
||||
$(SRC_DIR)/agent.c \
|
||||
$(SRC_DIR)/tools.c \
|
||||
$(SRC_DIR)/trigger_manager.c \
|
||||
$(SRC_DIR)/http_api.c \
|
||||
$(SRC_DIR)/mongoose.c \
|
||||
$(SRC_DIR)/debug.c
|
||||
|
||||
INCLUDES = \
|
||||
|
||||
@@ -51,11 +51,11 @@ Agents learn capabilities through skills — Nostr events that any agent can di
|
||||
|
||||
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.21
|
||||
## Current Status — v0.0.27
|
||||
|
||||
**Active build — this project is barely working. Experiment at your own risk.**
|
||||
|
||||
> Last release update: v0.0.21 — Add full skill_* tool family with schema, execution, dispatch, and README updates
|
||||
> Last release update: v0.0.27 — Add admin API docs/frontend brief and improve context readability with titled sections and pretty context.log formatting
|
||||
|
||||
- Connects to configured relays with auto-reconnect and relay state transition logging
|
||||
- Publishes configured startup events per relay as each relay becomes connected
|
||||
@@ -179,8 +179,20 @@ Relays are sourced exclusively from startup kind `10002` `r` tags.
|
||||
Options:
|
||||
|
||||
```
|
||||
./didactyl_static_x86_64 --config <path> # custom config file (default: ./config.json)
|
||||
./didactyl_static_x86_64 --debug <0-5> # log verbosity (0 none, 3 info, 5 trace)
|
||||
./didactyl_static_x86_64 --config <path> # custom config file (default: ./config.json)
|
||||
./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
|
||||
```
|
||||
|
||||
CLI debugger notes:
|
||||
|
||||
- `--test-tool` initializes Nostr, waits for at least one relay connection (up to 15s), then executes the selected tool.
|
||||
- Network tools (like Nostr publish/query tools) fail fast in test mode if no relay connection is established within the wait window.
|
||||
- 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"}'
|
||||
```
|
||||
|
||||
### Talk to it
|
||||
|
||||
@@ -42,6 +42,11 @@
|
||||
],
|
||||
"kind_1_limit": 10
|
||||
},
|
||||
"api": {
|
||||
"enabled": false,
|
||||
"port": 8484,
|
||||
"bind_address": "127.0.0.1"
|
||||
},
|
||||
"startup_events": [
|
||||
{
|
||||
"kind": 0,
|
||||
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
# Didactyl Admin HTTP API
|
||||
|
||||
## Overview
|
||||
|
||||
Didactyl exposes a localhost-only HTTP API for external tools and dashboards to inspect agent state, explore LLM context, craft prompts, and compare prompt variants. The API runs inside the same process as the agent — no separate server, no authentication required.
|
||||
|
||||
All responses are JSON. CORS headers are included on every response for browser access from any local origin.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Enable the API in `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"api": {
|
||||
"enabled": true,
|
||||
"port": 8484,
|
||||
"bind_address": "127.0.0.1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `enabled` | bool | `false` | Must be explicitly set to `true` to start the HTTP server |
|
||||
| `port` | int | `8484` | TCP port to listen on |
|
||||
| `bind_address` | string | `"127.0.0.1"` | Bind address — use `127.0.0.1` for localhost-only access |
|
||||
|
||||
The API is disabled by default. When disabled, no listener is created and no resources are consumed.
|
||||
|
||||
---
|
||||
|
||||
## CORS
|
||||
|
||||
Every response includes:
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin: *
|
||||
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
|
||||
Access-Control-Allow-Headers: Content-Type
|
||||
```
|
||||
|
||||
`OPTIONS` requests return `204 No Content` with these headers for browser preflight support.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### GET /api/status
|
||||
|
||||
Returns agent runtime status.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"name": "Didactyl",
|
||||
"version": "v0.0.26",
|
||||
"pubkey": "52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8",
|
||||
"relay_count": 4,
|
||||
"connected_relays": 4,
|
||||
"active_triggers": 0
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `name` | Agent display name constant |
|
||||
| `version` | Build version string |
|
||||
| `pubkey` | Agent public key in hex |
|
||||
| `relay_count` | Number of configured relays |
|
||||
| `connected_relays` | Number of currently connected relays |
|
||||
| `active_triggers` | Number of active triggered-skill subscriptions |
|
||||
|
||||
---
|
||||
|
||||
### GET /api/context/current
|
||||
|
||||
Returns the full LLM context message array that would be sent to the model right now. This is the same context the agent builds for an admin DM conversation.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"total_chars": 13131,
|
||||
"total_estimated_tokens": 3283,
|
||||
"messages": [
|
||||
{"role": "system", "content": "# Didactyl Agent\n\nYou are Didactyl..."},
|
||||
{"role": "system", "content": "This is your administrator! Admin pubkey..."},
|
||||
{"role": "system", "content": "Administrator kind 0 profile content..."},
|
||||
{"role": "system", "content": "Administrator kind 10002 relay-list content..."},
|
||||
{"role": "system", "content": "Startup events memory..."},
|
||||
{"role": "system", "content": "Adopted skills memory..."},
|
||||
{"role": "system", "content": "Administrator recent public notes..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `total_chars` | Total character count across all messages |
|
||||
| `total_estimated_tokens` | Rough token estimate using `chars / 4` heuristic |
|
||||
| `messages` | OpenAI-format messages array with role and content |
|
||||
|
||||
---
|
||||
|
||||
### GET /api/context/parts
|
||||
|
||||
Returns the context broken into labeled, individually-sized parts. Useful for understanding what consumes context budget.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"total_chars": 13131,
|
||||
"total_estimated_tokens": 3283,
|
||||
"parts": [
|
||||
{
|
||||
"name": "system_prompt",
|
||||
"role": "system",
|
||||
"chars": 1200,
|
||||
"estimated_tokens": 300,
|
||||
"content": "# Didactyl Agent..."
|
||||
},
|
||||
{
|
||||
"name": "admin_identity",
|
||||
"role": "system",
|
||||
"chars": 120,
|
||||
"estimated_tokens": 30,
|
||||
"content": "This is your administrator!..."
|
||||
},
|
||||
{
|
||||
"name": "admin_kind0",
|
||||
"role": "system",
|
||||
"chars": 450,
|
||||
"estimated_tokens": 113,
|
||||
"content": "Administrator kind 0 profile content..."
|
||||
},
|
||||
{
|
||||
"name": "admin_relay_list",
|
||||
"role": "system",
|
||||
"chars": 50,
|
||||
"estimated_tokens": 13,
|
||||
"content": "Administrator kind 10002 relay-list content..."
|
||||
},
|
||||
{
|
||||
"name": "startup_events",
|
||||
"role": "system",
|
||||
"chars": 4800,
|
||||
"estimated_tokens": 1200,
|
||||
"content": "Startup events memory..."
|
||||
},
|
||||
{
|
||||
"name": "adopted_skills",
|
||||
"role": "system",
|
||||
"chars": 2100,
|
||||
"estimated_tokens": 525,
|
||||
"content": "Adopted skills memory..."
|
||||
},
|
||||
{
|
||||
"name": "admin_notes",
|
||||
"role": "system",
|
||||
"chars": 680,
|
||||
"estimated_tokens": 170,
|
||||
"content": "Administrator recent public notes..."
|
||||
}
|
||||
],
|
||||
"messages": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**Part names:**
|
||||
|
||||
| Name | Description |
|
||||
|---|---|
|
||||
| `system_prompt` | The agent soul / system prompt (first system message) |
|
||||
| `admin_identity` | Admin pubkey identification message |
|
||||
| `admin_kind0` | Admin kind 0 profile metadata |
|
||||
| `admin_relay_list` | Admin kind 10002 relay list |
|
||||
| `startup_events` | Startup events memory block |
|
||||
| `adopted_skills` | Adopted skills behavioral instructions |
|
||||
| `admin_notes` | Admin recent kind 1 public notes |
|
||||
| `dm_history` | Recent DM conversation history |
|
||||
| `context_part` | Any other context message |
|
||||
|
||||
---
|
||||
|
||||
### 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.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"system": "You are a helpful assistant that writes tweets.",
|
||||
"user": "Write a tweet about AI agents on Nostr",
|
||||
"model": "claude-haiku-4.5"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `system` | yes | System prompt string |
|
||||
| `user` | yes | User message string |
|
||||
| `model` | no | Override the current model for this request only |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"response": "AI agents are finding their home on Nostr...",
|
||||
"model_used": "claude-haiku-4.5",
|
||||
"input_tokens_estimate": 85,
|
||||
"output_tokens_estimate": 42
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are Didactyl..."},
|
||||
{"role": "user", "content": "Tweet about the weather"}
|
||||
],
|
||||
"model": "claude-haiku-4.5",
|
||||
"max_turns": 5
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `messages` | yes | OpenAI-format messages array |
|
||||
| `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/compare
|
||||
|
||||
A/B testing: submit two prompt variants, both are executed sequentially, responses returned side-by-side. Each variant can optionally use a different model for cross-model comparison.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"variant_a": {
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are concise. No emoji."},
|
||||
{"role": "user", "content": "Say hi."}
|
||||
],
|
||||
"model": "claude-haiku-4.5",
|
||||
"max_turns": 1
|
||||
},
|
||||
"variant_b": {
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are verbose and friendly."},
|
||||
{"role": "user", "content": "Say hi."}
|
||||
],
|
||||
"model": "claude-haiku-4.5",
|
||||
"max_turns": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `variant_a` | yes | First prompt variant — same shape as `/api/prompt/run` request |
|
||||
| `variant_b` | yes | Second prompt variant — same shape as `/api/prompt/run` request |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"variant_a": {
|
||||
"success": true,
|
||||
"final_response": "Hi. How can I help?",
|
||||
"turns": [{"turn": 1, "tool_calls": []}],
|
||||
"model_used": "claude-haiku-4.5",
|
||||
"total_input_tokens_estimate": 21,
|
||||
"total_output_tokens_estimate": 6
|
||||
},
|
||||
"variant_b": {
|
||||
"success": true,
|
||||
"final_response": "Hey there! Nice to meet you! I am here and ready to help...",
|
||||
"turns": [{"turn": 1, "tool_calls": []}],
|
||||
"model_used": "claude-haiku-4.5",
|
||||
"total_input_tokens_estimate": 21,
|
||||
"total_output_tokens_estimate": 72
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Variant A runs first, then variant B. The original model config is restored after each variant completes.
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All error responses follow this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "description of what went wrong"
|
||||
}
|
||||
```
|
||||
|
||||
Common HTTP status codes:
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `200` | Success |
|
||||
| `204` | OPTIONS preflight success |
|
||||
| `400` | Bad request — missing or invalid parameters |
|
||||
| `404` | Endpoint not found |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
## Token Estimation
|
||||
|
||||
All token estimates use a simple `chars / 4` heuristic. This is a rough approximation that works well enough for English text across major model families. No real tokenizer is used.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- Binds to `127.0.0.1` only — not accessible from the network
|
||||
- No authentication — this is a local development and administration tool
|
||||
- The `api.enabled` config flag defaults to `false` and must be explicitly opted in
|
||||
- Prompt execution endpoints have full tool access equivalent to admin-tier DM conversations
|
||||
- The agent process must be running for the API to be available
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
The HTTP server is embedded in the didactyl process using the Mongoose library. It runs in the same thread as the main poll loop — each iteration calls `http_api_poll()` which does non-blocking accept/read/write. This avoids threading complexity and gives the API direct access to all agent state.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph didactyl process
|
||||
MAIN[main loop] --> POLL[nostr_handler_poll]
|
||||
MAIN --> TPOLL[trigger_manager_poll]
|
||||
MAIN --> HPOLL[http_api_poll]
|
||||
HPOLL --> ROUTER[request router]
|
||||
ROUTER --> AGENT[agent internals]
|
||||
ROUTER --> LLM[LLM client]
|
||||
ROUTER --> TOOLS[tools context]
|
||||
ROUTER --> CONFIG[config]
|
||||
ROUTER --> TRIGGERS[trigger_manager]
|
||||
end
|
||||
BROWSER[Web Dashboard] -- HTTP localhost:8484 --> HPOLL
|
||||
```
|
||||
|
||||
### Source Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/http_api.c` | HTTP server, request router, all endpoint handlers |
|
||||
| `src/http_api.h` | Public API: `http_api_init`, `http_api_poll`, `http_api_cleanup` |
|
||||
| `src/mongoose.c` | Mongoose embedded HTTP library |
|
||||
| `src/mongoose.h` | Mongoose header |
|
||||
|
||||
---
|
||||
|
||||
## Future Endpoints
|
||||
|
||||
The following endpoints are planned but not yet implemented:
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/api/config` | Current runtime config with redacted secrets |
|
||||
| GET | `/api/events/soul` | Fetch agent soul event |
|
||||
| PUT | `/api/events/soul` | Update soul content |
|
||||
| GET | `/api/events/skills` | List published skills |
|
||||
| GET | `/api/events/skills/:slug` | Fetch skill by slug |
|
||||
| PUT | `/api/events/skills/:slug` | Update skill |
|
||||
| GET | `/api/events/adoption` | Fetch adoption list |
|
||||
| GET | `/api/events/profile` | Fetch agent profile |
|
||||
| PUT | `/api/events/profile` | Update agent profile |
|
||||
| GET | `/api/triggers` | List active triggers |
|
||||
| GET | `/api/model` | Current model config |
|
||||
| PUT | `/api/model` | Update model config |
|
||||
| GET | `/api/models` | List available models |
|
||||
| GET | `/api/relays` | Relay connection status |
|
||||
| GET | `/api/tools` | List tool schemas |
|
||||
| POST | `/api/tools/:name/execute` | Execute a tool directly |
|
||||
| GET | `/api/context/log` | Recent context.log entries |
|
||||
| POST | `/api/context/preview` | Dry-run context preview |
|
||||
@@ -0,0 +1,268 @@
|
||||
# Crash Fixes Reference
|
||||
|
||||
This document catalogues crashes that have been diagnosed and fixed in Didactyl.
|
||||
Use it as a reference when investigating future crashes — the symptoms, root causes,
|
||||
and investigation techniques described here may save significant debugging time.
|
||||
|
||||
---
|
||||
|
||||
## 1. Silent process exit on relay disconnect — SIGPIPE (v0.0.24)
|
||||
|
||||
**Date**: 2026-03-02
|
||||
**Version**: v0.0.23 → fixed in v0.0.24
|
||||
**Severity**: Critical — process terminates without any log output
|
||||
**Files changed**: `src/main.c`
|
||||
|
||||
### Symptoms
|
||||
|
||||
- The process exits cleanly to the shell prompt with **no error message**.
|
||||
- The `[didactyl] shutting down` log line does **not** appear.
|
||||
- No coredump is generated.
|
||||
- The last log lines show all relays transitioning from `connected → disconnected`
|
||||
and then immediately `disconnected → connected`.
|
||||
- The crash typically follows a period of network instability or DNS resolution
|
||||
failure — for example, an LLM HTTP request failing with
|
||||
`curl=Could not resolve hostname`.
|
||||
|
||||
### Root cause
|
||||
|
||||
`SIGPIPE` was never handled. The default OS action for `SIGPIPE` is to terminate
|
||||
the process immediately — no signal handler runs, no cleanup occurs, no coredump
|
||||
is written.
|
||||
|
||||
The TLS write path in `nostr_core_lib` uses `SSL_write()`, which internally calls
|
||||
`write()` on the underlying socket. When a `wss://` relay drops its TCP connection
|
||||
and the relay pool attempts to write to that socket, the kernel delivers `SIGPIPE`.
|
||||
|
||||
The plain-TCP path for `ws://` connections was already protected by using
|
||||
`MSG_NOSIGNAL` on `send()` calls, but the TLS path had no equivalent protection.
|
||||
|
||||
### How it was diagnosed
|
||||
|
||||
1. **No crash message or coredump** ruled out `SIGSEGV`, `SIGABRT`, and OOM.
|
||||
2. **No shutdown log** ruled out a graceful exit via the signal handler.
|
||||
3. `dmesg` and `journalctl` showed no OOM-kill or segfault entries.
|
||||
4. Searching the entire codebase for `SIGPIPE` or `SIG_IGN` returned zero results.
|
||||
5. The websocket TLS transport was confirmed to use `SSL_write()` without
|
||||
`MSG_NOSIGNAL` or any per-thread signal mask.
|
||||
6. The timeline matched: relay disconnects occurred, the next poll cycle attempted
|
||||
a write to a dead TLS socket, and the process vanished.
|
||||
|
||||
### Fix
|
||||
|
||||
Added `signal(SIGPIPE, SIG_IGN)` in `main()` alongside the existing `SIGINT` and
|
||||
`SIGTERM` handlers:
|
||||
|
||||
```c
|
||||
signal(SIGINT, signal_handler);
|
||||
signal(SIGTERM, signal_handler);
|
||||
signal(SIGPIPE, SIG_IGN); /* ← added */
|
||||
```
|
||||
|
||||
This causes `SSL_write()` and any other write to a broken pipe to return `-1` with
|
||||
`errno = EPIPE` instead of killing the process. The relay pool and libcurl already
|
||||
handle write errors gracefully.
|
||||
|
||||
### How to recognise this class of bug in the future
|
||||
|
||||
- Process disappears without any log output or coredump.
|
||||
- Happens after network disruption or relay disconnects.
|
||||
- `ulimit -c` may be 0, but even with unlimited core size, `SIGPIPE` does not
|
||||
produce a coredump by default.
|
||||
- Any new network transport layer added to the project should be audited for
|
||||
`SIGPIPE` protection.
|
||||
|
||||
---
|
||||
|
||||
## 2. Use-after-free in `execute_nostr_list_manage` — SIGSEGV (v0.0.24)
|
||||
|
||||
**Date**: 2026-03-01 (coredump), fixed 2026-03-02
|
||||
**Version**: v0.0.23 → fixed in v0.0.24
|
||||
**Severity**: Critical — segmentation fault during tool execution
|
||||
**Files changed**: `src/tools.c`
|
||||
|
||||
### Symptoms
|
||||
|
||||
- `SIGSEGV` crash during execution of the `nostr_list_manage` tool.
|
||||
- Coredump shows the crash at `src/tools.c:29` inside `json_error()`, but with
|
||||
heavily corrupted stack frames — the real crash site is in
|
||||
`execute_nostr_list_manage()`.
|
||||
- The corrupted stack is characteristic of heap corruption from use-after-free.
|
||||
|
||||
### Root cause
|
||||
|
||||
In `execute_nostr_list_manage()`, the `action` pointer was obtained from the
|
||||
`args` cJSON tree:
|
||||
|
||||
```c
|
||||
cJSON* action = cJSON_GetObjectItemCaseSensitive(args, "action");
|
||||
```
|
||||
|
||||
Later, `args` was freed:
|
||||
|
||||
```c
|
||||
cJSON_Delete(args); /* frees the entire tree including action */
|
||||
```
|
||||
|
||||
But `action->valuestring` was still accessed afterwards:
|
||||
|
||||
```c
|
||||
cJSON_AddStringToObject(out, "action", action->valuestring); /* dangling pointer */
|
||||
```
|
||||
|
||||
After `cJSON_Delete(args)`, the `action` pointer is dangling. Accessing
|
||||
`action->valuestring` reads freed heap memory, which may contain arbitrary data
|
||||
or may have been reallocated for another purpose.
|
||||
|
||||
### How it was diagnosed
|
||||
|
||||
1. The coredump was extracted from systemd-coredump storage:
|
||||
```
|
||||
zstd -d /var/lib/systemd/coredump/core.didactyl_static.*.zst -o /tmp/core.bin
|
||||
gdb ./didactyl_static_x86_64_debug /tmp/core.bin -batch -ex "bt"
|
||||
```
|
||||
2. The backtrace showed `execute_nostr_list_manage` with corrupted frames.
|
||||
3. Code review of the function identified the `cJSON_Delete(args)` call occurring
|
||||
before the last use of `action->valuestring`.
|
||||
|
||||
### Fix
|
||||
|
||||
Deferred `cJSON_Delete(args)` until after `action->valuestring` has been consumed
|
||||
by `cJSON_AddStringToObject()`. The free now occurs immediately after the last use:
|
||||
|
||||
```c
|
||||
cJSON_AddStringToObject(out, "action", action->valuestring);
|
||||
cJSON_Delete(args); /* ← moved here, after last use of action */
|
||||
```
|
||||
|
||||
All early-return error paths before this point also received their own
|
||||
`cJSON_Delete(args)` call to prevent leaks.
|
||||
|
||||
### How to recognise this class of bug in the future
|
||||
|
||||
- `SIGSEGV` with corrupted or nonsensical stack frames.
|
||||
- Crash location reported by GDB does not match the actual buggy code — the
|
||||
corruption happened earlier.
|
||||
- Any function that calls `cJSON_Delete()` on a parent object should be audited
|
||||
to ensure no child pointers are used afterwards.
|
||||
- Pattern to watch for:
|
||||
```c
|
||||
cJSON* child = cJSON_GetObjectItemCaseSensitive(parent, "key");
|
||||
/* ... */
|
||||
cJSON_Delete(parent);
|
||||
/* ... */
|
||||
use(child->valuestring); /* BUG: child is dangling */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. DM subscription not receiving incoming kind 4 events — INVESTIGATION (v0.0.24)
|
||||
|
||||
**Date**: 2026-03-02
|
||||
**Version**: v0.0.24
|
||||
**Severity**: High — agent does not respond to incoming DMs
|
||||
**Status**: Under investigation — diagnostic logging added
|
||||
**Files changed**: `src/nostr_handler.c`
|
||||
|
||||
### Symptoms
|
||||
|
||||
- The agent starts up normally, connects to relays, and can **send** kind 4 DMs.
|
||||
- Incoming kind 4 messages posted to the same relays are never processed.
|
||||
- No error messages appear in the log — the agent simply sits idle after sending
|
||||
its startup DM.
|
||||
- The last log lines show successful outbound DM publishing but no inbound event
|
||||
processing.
|
||||
|
||||
### Possible root causes (under investigation)
|
||||
|
||||
1. **Events never reach `on_event()` callback** — The relay pool library only
|
||||
calls `on_event()` when it receives an `EVENT` message matching the
|
||||
subscription ID. If the subscription was silently closed, errored, or not
|
||||
re-established after a relay reconnect, no events would be delivered. The
|
||||
relay could also be rejecting the subscription filter.
|
||||
|
||||
2. **`since` filter timing mismatch** — `g_start_time` is set to `time(NULL)`
|
||||
during `nostr_handler_init()` (early in startup), but the DM subscription
|
||||
is created much later (after admin context subscription, startup event
|
||||
reconciliation, and startup DM sending). If the sender's clock is behind
|
||||
the server's clock, their `created_at` timestamp would be before
|
||||
`g_start_time` and the relay would filter them out.
|
||||
|
||||
3. **Sender tier filtering** — If the sender's pubkey doesn't match the admin
|
||||
pubkey and isn't in the WoT contact list, the message is classified as
|
||||
`DIDACTYL_SENDER_STRANGER` and silently dropped (only a DEBUG_LOG at
|
||||
level 4).
|
||||
|
||||
4. **Signature verification failure** — If `verify_signatures` is enabled and
|
||||
the event has an invalid signature, it is dropped with only a WARN log.
|
||||
|
||||
5. **Pool-level deduplication** — The relay pool's `is_event_seen()` cache is
|
||||
shared across all subscriptions. If an event ID was somehow marked as seen
|
||||
by another subscription, it would be silently dropped.
|
||||
|
||||
### Diagnostic logging added
|
||||
|
||||
TRACE-level (level 5 / `--debug 5`) logs were added at every decision point
|
||||
in the `on_event()` callback and the `nostr_handler_subscribe_dms()` setup:
|
||||
|
||||
| Log message prefix | What it tells you |
|
||||
|---|---|
|
||||
| `DEBUG on_event ENTRY` | Callback was called — events are reaching didactyl |
|
||||
| `DEBUG on_event NULL guard` | Event, config, or callback pointer is NULL |
|
||||
| `DEBUG on_event: missing required fields` | Event JSON is malformed |
|
||||
| `DEBUG on_event: kind=N id=... from=...` | Event kind, ID, and sender pubkey |
|
||||
| `DEBUG on_event: ignoring non-kind4` | Event is not kind 4 |
|
||||
| `DEBUG on_event: no p-tag found` | Kind 4 event has no `p` tag |
|
||||
| `DEBUG on_event: p-tag mismatch` | `p` tag doesn't match agent's pubkey |
|
||||
| `DEBUG on_event: sender=... tier=N` | Sender tier classification |
|
||||
| `DEBUG on_eose called` | EOSE received from relays |
|
||||
| `DEBUG DM subscription filter` | Full subscription filter JSON |
|
||||
| `DEBUG DM subscription g_start_time=...` | `since` timestamp and time delta |
|
||||
| `DEBUG DM subscription sub=...` | Subscription pointer (confirms creation) |
|
||||
|
||||
### How to use the diagnostic logs
|
||||
|
||||
1. Build with `./build_static.sh --debug`
|
||||
2. Run with `--debug 5` to enable TRACE output
|
||||
3. Send a kind 4 DM to the agent
|
||||
4. Check the output:
|
||||
- **No `on_event ENTRY` lines** → problem is at the relay/subscription level
|
||||
- **`on_event ENTRY` appears but processing stops** → the specific drop-point
|
||||
log identifies the exact filter that rejected the event
|
||||
5. Check the `DM subscription filter` log to verify the `since` timestamp and
|
||||
`#p` tag are correct
|
||||
|
||||
### How to recognise this class of bug in the future
|
||||
|
||||
- Agent can send but not receive — asymmetric connectivity.
|
||||
- No error messages in the log — silent event filtering.
|
||||
- The subscription filter (`since`, `#p`, `kinds`) may not match what the
|
||||
sender is actually publishing.
|
||||
- Clock skew between sender and receiver can cause `since` filter mismatches.
|
||||
- Relay reconnections may not automatically re-subscribe.
|
||||
|
||||
---
|
||||
|
||||
## General debugging checklist
|
||||
|
||||
When investigating a crash in Didactyl, work through these steps:
|
||||
|
||||
1. **Check for coredumps**: `ls /var/lib/systemd/coredump/ | grep didactyl`
|
||||
2. **Check dmesg/journalctl**: `dmesg | grep -i 'didactyl\|oom\|segfault'`
|
||||
3. **Check for the shutdown log line**: If `[didactyl] shutting down` is missing,
|
||||
the process was killed by a signal that bypassed the handler.
|
||||
4. **Check ulimit**: `ulimit -c` — if 0, coredumps are disabled.
|
||||
5. **Extract and analyse coredumps**:
|
||||
```bash
|
||||
zstd -d /var/lib/systemd/coredump/core.didactyl*.zst -o /tmp/core.bin
|
||||
gdb ./didactyl_static_x86_64_debug /tmp/core.bin -batch -ex "bt full"
|
||||
```
|
||||
6. **Common silent killers**:
|
||||
- `SIGPIPE` — process writes to a broken socket/pipe
|
||||
- `SIGKILL` — OOM killer or external kill
|
||||
- `SIGBUS` — memory-mapped file issues
|
||||
7. **Common crash causes**:
|
||||
- Use-after-free on cJSON child pointers after parent deletion
|
||||
- Buffer overflows in fixed-size stack buffers
|
||||
- NULL pointer dereference on failed allocations
|
||||
- Re-entrancy in callbacks during internal `nostr_relay_pool_poll()` calls
|
||||
@@ -0,0 +1,419 @@
|
||||
# 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,469 @@
|
||||
# Didactyl Admin HTTP API — Architecture & Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Add a localhost-only HTTP API to didactyl so an external web dashboard can inspect and manage the agent at runtime. No authentication required — binding to `127.0.0.1` only. All responses are JSON. CORS headers included for browser access from any local origin.
|
||||
|
||||
The web frontend is a separate project; this plan covers only the C-side HTTP server and API endpoints.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph didactyl process
|
||||
MAIN[main loop] --> POLL[nostr_handler_poll]
|
||||
MAIN --> TPOLL[trigger_manager_poll]
|
||||
MAIN --> HPOLL[http_api_poll]
|
||||
HPOLL --> ROUTER[request router]
|
||||
ROUTER --> AGENT[agent internals]
|
||||
ROUTER --> NOSTR[nostr_handler]
|
||||
ROUTER --> TOOLS[tools context]
|
||||
ROUTER --> CONFIG[config]
|
||||
ROUTER --> TRIGGERS[trigger_manager]
|
||||
end
|
||||
BROWSER[Web Dashboard] -- HTTP localhost:8484 --> HPOLL
|
||||
```
|
||||
|
||||
### HTTP Library Choice
|
||||
|
||||
Use a minimal embedded HTTP server. Two good options for C with no extra dependencies:
|
||||
|
||||
1. **mongoose** (single `mongoose.c` + `mongoose.h`) — battle-tested, MIT license, supports polling model
|
||||
2. **microhttpd** (libmicrohttpd) — GNU project, available as system package
|
||||
|
||||
**Recommendation: mongoose** — it is a single-file drop-in, works with the existing poll-based main loop, and requires zero system dependencies. Just add `mongoose.c` and `mongoose.h` to the project.
|
||||
|
||||
### Integration Pattern
|
||||
|
||||
The HTTP server runs in the same thread as the main poll loop. Each iteration calls `http_api_poll()` which does non-blocking accept/read/write via mongoose's `mg_mgr_poll()`. This avoids threading complexity and gives the API direct access to all agent state.
|
||||
|
||||
---
|
||||
|
||||
## Config Extension
|
||||
|
||||
```json
|
||||
{
|
||||
"api": {
|
||||
"enabled": true,
|
||||
"port": 8484,
|
||||
"bind_address": "127.0.0.1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Defaults: enabled=false, port=8484, bind=127.0.0.1.
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All endpoints return JSON. All mutations use POST/PUT/DELETE. All reads use GET.
|
||||
|
||||
### Agent Identity & Status
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/api/status` | Agent runtime status: pubkey, display name, version, uptime, connected relay count, trigger count |
|
||||
| GET | `/api/config` | Current runtime config (redacted: nsec/api_key masked) |
|
||||
|
||||
### Nostr Events — Read & Edit
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| 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/adoption` | Fetch kind 10123 adoption list |
|
||||
| GET | `/api/events/startup` | List startup events from config |
|
||||
| GET | `/api/events/profile` | Fetch agent kind 0 profile |
|
||||
| PUT | `/api/events/profile` | Update agent kind 0 profile, republish |
|
||||
| GET | `/api/events/query` | Generic Nostr query — pass filter as query params or JSON body |
|
||||
|
||||
### Context Inspector
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/api/context/current` | Build and return the full context that would be sent to the LLM right now, broken into labeled parts |
|
||||
| GET | `/api/context/parts` | Return context parts with individual sizes (bytes and estimated tokens) |
|
||||
| GET | `/api/context/log` | Return recent context.log entries (last N blocks, configurable via ?limit=) |
|
||||
| POST | `/api/context/preview` | Accept a modified context structure, return what the LLM payload would look like (dry run, no send) |
|
||||
|
||||
### Context Parts Response Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"total_chars": 12450,
|
||||
"total_estimated_tokens": 3112,
|
||||
"parts": [
|
||||
{
|
||||
"name": "system_prompt",
|
||||
"role": "system",
|
||||
"chars": 1200,
|
||||
"estimated_tokens": 300,
|
||||
"content": "# Didactyl Agent..."
|
||||
},
|
||||
{
|
||||
"name": "admin_identity",
|
||||
"role": "system",
|
||||
"chars": 450,
|
||||
"estimated_tokens": 112,
|
||||
"content": "This is your administrator!..."
|
||||
},
|
||||
{
|
||||
"name": "admin_kind0",
|
||||
"role": "system",
|
||||
"chars": 320,
|
||||
"estimated_tokens": 80,
|
||||
"content": "Administrator kind 0 profile..."
|
||||
},
|
||||
{
|
||||
"name": "startup_events",
|
||||
"role": "system",
|
||||
"chars": 4800,
|
||||
"estimated_tokens": 1200,
|
||||
"content": "Startup events memory..."
|
||||
},
|
||||
{
|
||||
"name": "adopted_skills",
|
||||
"role": "system",
|
||||
"chars": 2100,
|
||||
"estimated_tokens": 525,
|
||||
"content": "Adopted skills memory..."
|
||||
},
|
||||
{
|
||||
"name": "dm_history",
|
||||
"role": "mixed",
|
||||
"chars": 2400,
|
||||
"estimated_tokens": 600,
|
||||
"turns": 8
|
||||
},
|
||||
{
|
||||
"name": "admin_notes",
|
||||
"role": "system",
|
||||
"chars": 680,
|
||||
"estimated_tokens": 170,
|
||||
"content": "Administrator recent public notes..."
|
||||
},
|
||||
{
|
||||
"name": "tools_schema",
|
||||
"chars": 500,
|
||||
"estimated_tokens": 125,
|
||||
"tool_count": 28
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Triggers
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/api/triggers` | List active triggers with status (wraps existing trigger_manager_status_json) |
|
||||
|
||||
### Model / LLM
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/api/model` | Current model config (wraps existing model_get) |
|
||||
| PUT | `/api/model` | Update model config (wraps existing model_set) |
|
||||
| GET | `/api/models` | List available models from provider (wraps existing model_list) |
|
||||
|
||||
### Relays
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/api/relays` | Relay connection status (wraps existing relay_status tool) |
|
||||
|
||||
### Prompt Crafting & Execution
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| POST | `/api/prompt/run` | Submit a custom messages array with tools enabled; returns full LLM response including tool calls and results |
|
||||
| POST | `/api/prompt/run-simple` | Submit system prompt + user message; returns LLM text response (no tools) |
|
||||
| POST | `/api/prompt/compare` | A/B test: submit two prompt variants, run both, return side-by-side responses |
|
||||
|
||||
#### POST /api/prompt/run
|
||||
|
||||
Send a fully crafted messages array to the LLM with the full tool set enabled. The agent executes tool calls and returns the complete conversation.
|
||||
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are Didactyl..."},
|
||||
{"role": "system", "content": "Adopted skills memory..."},
|
||||
{"role": "user", "content": "Tweet about the weather"}
|
||||
],
|
||||
"model": "claude-haiku-4.5",
|
||||
"max_turns": 5,
|
||||
"tools_enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"final_response": "Done! I posted a tweet about the weather.",
|
||||
"turns": [
|
||||
{
|
||||
"turn": 1,
|
||||
"tool_calls": [
|
||||
{"name": "nostr_post", "arguments": "...", "result": "..."}
|
||||
]
|
||||
}
|
||||
],
|
||||
"model_used": "claude-haiku-4.5",
|
||||
"total_input_tokens_estimate": 3200,
|
||||
"total_output_tokens_estimate": 180
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /api/prompt/run-simple
|
||||
|
||||
Quick iteration on prompt wording without tools.
|
||||
|
||||
```json
|
||||
{
|
||||
"system": "You are a helpful assistant that writes tweets...",
|
||||
"user": "Write a tweet about AI agents on Nostr",
|
||||
"model": "claude-haiku-4.5"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"response": "AI agents are finding their home on Nostr...",
|
||||
"model_used": "claude-haiku-4.5",
|
||||
"input_tokens_estimate": 85,
|
||||
"output_tokens_estimate": 42
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /api/prompt/compare
|
||||
|
||||
A/B testing: submit two prompt variants, both are executed, responses returned side-by-side.
|
||||
|
||||
```json
|
||||
{
|
||||
"variant_a": {
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are Didactyl. Keep responses under 280 chars."},
|
||||
{"role": "user", "content": "Tweet about your new skill"}
|
||||
],
|
||||
"model": "claude-haiku-4.5",
|
||||
"tools_enabled": true
|
||||
},
|
||||
"variant_b": {
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are Didactyl. Be concise. No markdown. No emoji."},
|
||||
{"role": "user", "content": "Tweet about your new skill"}
|
||||
],
|
||||
"model": "claude-haiku-4.5",
|
||||
"tools_enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"variant_a": {
|
||||
"final_response": "Just picked up the tweet-composer skill! ...",
|
||||
"turns": [],
|
||||
"model_used": "claude-haiku-4.5",
|
||||
"total_input_tokens_estimate": 3200,
|
||||
"total_output_tokens_estimate": 95
|
||||
},
|
||||
"variant_b": {
|
||||
"final_response": "New skill acquired: tweet-composer. ...",
|
||||
"turns": [],
|
||||
"model_used": "claude-haiku-4.5",
|
||||
"total_input_tokens_estimate": 3100,
|
||||
"total_output_tokens_estimate": 78
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The compare endpoint runs variant_a first, then variant_b sequentially. Each variant can optionally use a different model for cross-model comparison.
|
||||
|
||||
#### Prompt Crafting Workflow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
LOAD[GET /api/context/parts] --> EDIT[Edit parts in UI]
|
||||
EDIT --> PREVIEW[POST /api/context/preview]
|
||||
PREVIEW --> FIRE[POST /api/prompt/run]
|
||||
FIRE --> COMPARE{Want to compare?}
|
||||
COMPARE -- Yes --> AB[POST /api/prompt/compare]
|
||||
COMPARE -- No --> PERSIST{Like the result?}
|
||||
AB --> PERSIST
|
||||
PERSIST -- Yes --> SAVE[PUT /api/events/soul or skills]
|
||||
PERSIST -- No --> EDIT
|
||||
```
|
||||
|
||||
### Tools
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/api/tools` | List all registered tool schemas |
|
||||
| POST | `/api/tools/:name/execute` | Execute a tool by name with JSON body as args (admin-only equivalent) |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/http_api.c` | HTTP server, request router, endpoint handlers |
|
||||
| `src/http_api.h` | Public API: init, poll, cleanup |
|
||||
| `vendor/mongoose.c` | Mongoose HTTP library (single file) |
|
||||
| `vendor/mongoose.h` | Mongoose header |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/config.h` | Add `api_config_t` struct to `didactyl_config_t` |
|
||||
| `src/config.c` | Parse `api` config section |
|
||||
| `src/main.c` | Call `http_api_init()`, add `http_api_poll()` to main loop, call `http_api_cleanup()` on shutdown |
|
||||
| `src/agent.h` | Expose `agent_build_context_parts_json()` for context inspector |
|
||||
| `src/agent.c` | Implement `agent_build_context_parts_json()` that builds context and returns labeled parts with sizes |
|
||||
| `Makefile` | Add `vendor/mongoose.c` and `src/http_api.c` to SRCS, add `-Ivendor` to INCLUDES |
|
||||
|
||||
### http_api.h
|
||||
|
||||
```c
|
||||
#ifndef DIDACTYL_HTTP_API_H
|
||||
#define DIDACTYL_HTTP_API_H
|
||||
|
||||
#include "config.h"
|
||||
#include "tools.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
typedef struct {
|
||||
didactyl_config_t* cfg;
|
||||
tools_context_t* tools_ctx;
|
||||
struct trigger_manager* trigger_manager;
|
||||
} http_api_context_t;
|
||||
|
||||
int http_api_init(http_api_context_t* ctx);
|
||||
int http_api_poll(int timeout_ms);
|
||||
void http_api_cleanup(void);
|
||||
|
||||
#endif
|
||||
```
|
||||
|
||||
### Main Loop Integration
|
||||
|
||||
```c
|
||||
// In main.c, after agent_init and trigger_manager_init:
|
||||
http_api_context_t api_ctx = {
|
||||
.cfg = &cfg,
|
||||
.tools_ctx = &g_tools_ctx, // need to expose from agent
|
||||
.trigger_manager = &trigger_manager
|
||||
};
|
||||
|
||||
if (cfg.api.enabled) {
|
||||
if (http_api_init(&api_ctx) != 0) {
|
||||
DEBUG_WARN("HTTP API failed to start");
|
||||
}
|
||||
}
|
||||
|
||||
// In main loop:
|
||||
while (g_running) {
|
||||
nostr_handler_poll(100);
|
||||
trigger_manager_poll(&trigger_manager);
|
||||
if (cfg.api.enabled) {
|
||||
http_api_poll(0); // non-blocking
|
||||
}
|
||||
nanosleep(...);
|
||||
}
|
||||
|
||||
// On shutdown:
|
||||
if (cfg.api.enabled) {
|
||||
http_api_cleanup();
|
||||
}
|
||||
```
|
||||
|
||||
### Request Router Pattern
|
||||
|
||||
```c
|
||||
static void http_handler(struct mg_connection* c, int ev, void* ev_data) {
|
||||
if (ev == MG_EV_HTTP_MSG) {
|
||||
struct mg_http_message* hm = ev_data;
|
||||
|
||||
// Add CORS headers to all responses
|
||||
// Route by method + path prefix
|
||||
|
||||
if (mg_match(hm->uri, mg_str("/api/status"), NULL) && is_get(hm)) {
|
||||
handle_status(c, hm);
|
||||
} else if (mg_match(hm->uri, mg_str("/api/context/parts"), NULL) && is_get(hm)) {
|
||||
handle_context_parts(c, hm);
|
||||
} else if (mg_match(hm->uri, mg_str("/api/events/skills/*"), NULL)) {
|
||||
handle_skill_by_slug(c, hm);
|
||||
}
|
||||
// ... etc
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add `api_config_t` to config and parse it
|
||||
2. Vendor mongoose.c/mongoose.h, update Makefile
|
||||
3. Create `src/http_api.c` with init/poll/cleanup skeleton + CORS
|
||||
4. Wire into main.c poll loop
|
||||
5. Implement read-only endpoints first: `/api/status`, `/api/config`, `/api/relays`, `/api/model`, `/api/tools`, `/api/triggers`
|
||||
6. Implement Nostr event endpoints: `/api/events/soul`, `/api/events/skills`, `/api/events/adoption`, `/api/events/profile`, `/api/events/startup`
|
||||
7. Implement context inspector: `/api/context/parts`, `/api/context/current`, `/api/context/log`
|
||||
8. Implement mutation endpoints: PUT soul, PUT skills, PUT model, PUT profile
|
||||
9. Implement tool execution endpoint: POST `/api/tools/:name/execute`
|
||||
10. Implement context preview: POST `/api/context/preview`
|
||||
11. Test all endpoints via curl
|
||||
12. Update documentation
|
||||
|
||||
---
|
||||
|
||||
## CORS Headers
|
||||
|
||||
Every response includes:
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin: *
|
||||
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
|
||||
Access-Control-Allow-Headers: Content-Type
|
||||
```
|
||||
|
||||
OPTIONS requests return 204 with these headers (preflight support).
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Binds to `127.0.0.1` only — not accessible from network
|
||||
- No authentication — this is a local dev tool
|
||||
- The `api.enabled` config flag defaults to `false` so it must be explicitly opted in
|
||||
- Tool execution endpoint gives full admin-tier access — acceptable for localhost dev dashboard
|
||||
- Config endpoint redacts `nsec` and `api_key` fields
|
||||
|
||||
---
|
||||
|
||||
## Token Estimation
|
||||
|
||||
For the context size display, use a simple heuristic: `estimated_tokens = chars / 4`. This is a rough approximation that works well enough for English text with the major model families. No need for a real tokenizer.
|
||||
@@ -0,0 +1,301 @@
|
||||
# Didactyl Admin Web Frontend — Project Brief
|
||||
|
||||
## What Is Didactyl?
|
||||
|
||||
Didactyl is a sovereign AI agent that lives on Nostr. It connects to Nostr relays, listens for encrypted DMs from its administrator, reasons with an LLM, and takes actions — posting events, querying relays, running shell commands, managing skills. Everything the agent knows and does is stored as Nostr events.
|
||||
|
||||
The agent is a C binary that runs on a server. It has no web interface of its own — all interaction happens through Nostr DMs.
|
||||
|
||||
## What We Are Building
|
||||
|
||||
A **local web admin dashboard** that connects to the running didactyl agent via a localhost HTTP API. The dashboard is a prompt crafting and agent inspection tool for the administrator.
|
||||
|
||||
This is **not** a chat interface. The administrator already chats with the agent through Nostr DMs. This dashboard is for:
|
||||
|
||||
1. **Inspecting** what the agent sees — its full LLM context, broken into labeled parts with token counts
|
||||
2. **Crafting** custom prompts — editing system prompts, user messages, and context pieces
|
||||
3. **Running** prompts against the LLM — with or without the agent tool set
|
||||
4. **Comparing** prompt variants side-by-side — A/B testing different prompt wordings or models
|
||||
|
||||
---
|
||||
|
||||
## The API
|
||||
|
||||
The didactyl agent exposes a localhost-only HTTP API on port `8484` by default. Full API documentation is in `docs/API.md`. All endpoints return JSON with CORS headers.
|
||||
|
||||
### Base URL
|
||||
|
||||
```
|
||||
http://127.0.0.1:8484
|
||||
```
|
||||
|
||||
### Currently Implemented Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/api/status` | Agent runtime status — name, version, pubkey, relay count, trigger count |
|
||||
| GET | `/api/context/current` | Full LLM context messages array with total char/token counts |
|
||||
| GET | `/api/context/parts` | Context broken into labeled parts with individual sizes |
|
||||
| POST | `/api/prompt/run-simple` | Simple prompt: system + user message, no tools, returns text |
|
||||
| POST | `/api/prompt/run` | Full prompt: messages array with tools enabled, returns conversation trace |
|
||||
| POST | `/api/prompt/compare` | A/B test: two prompt variants run sequentially, responses side-by-side |
|
||||
|
||||
### Planned Future Endpoints
|
||||
|
||||
These are not yet implemented but are on the roadmap:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/api/config` | Runtime config with redacted secrets |
|
||||
| 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 | `/api/events/profile` | Agent Nostr profile |
|
||||
| GET | `/api/model` | Current LLM model config |
|
||||
| PUT | `/api/model` | Change model at runtime |
|
||||
| GET | `/api/models` | List available models from provider |
|
||||
| GET | `/api/tools` | List all tool schemas |
|
||||
| POST | `/api/tools/:name/execute` | Execute a tool directly |
|
||||
| GET | `/api/triggers` | Active trigger subscriptions |
|
||||
| GET | `/api/relays` | Relay connection status |
|
||||
|
||||
---
|
||||
|
||||
## Core User Workflows
|
||||
|
||||
### 1. Context Inspector
|
||||
|
||||
The primary read-only workflow. The admin wants to understand what the agent sees when it processes a message.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
LOAD[Load /api/context/parts] --> DISPLAY[Display parts list]
|
||||
DISPLAY --> DETAIL[Click part to expand content]
|
||||
DETAIL --> TOKENS[Show char count and token estimate per part]
|
||||
TOKENS --> TOTAL[Show total context size]
|
||||
```
|
||||
|
||||
**What to show:**
|
||||
- A list/table of context parts with name, role, character count, estimated tokens
|
||||
- Total context size as a summary bar or header
|
||||
- Expandable content for each part
|
||||
- The parts are: system_prompt, admin_identity, admin_kind0, admin_relay_list, startup_events, adopted_skills, admin_notes, dm_history
|
||||
|
||||
### 2. Simple Prompt Crafting
|
||||
|
||||
Quick iteration on prompt wording without tools.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
WRITE[Write system prompt + user message] --> RUN[POST /api/prompt/run-simple]
|
||||
RUN --> RESULT[Display response text]
|
||||
RESULT --> EDIT[Edit and re-run]
|
||||
EDIT --> RUN
|
||||
```
|
||||
|
||||
**What to show:**
|
||||
- Two text areas: system prompt, user message
|
||||
- Optional model override dropdown/input
|
||||
- Run button
|
||||
- Response display with model used and token estimates
|
||||
|
||||
### 3. Full Prompt with Tools
|
||||
|
||||
Craft a complete messages array and run it with the agent tool set.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
CONTEXT[Load context from /api/context/parts] --> EDIT[Edit/rearrange context parts]
|
||||
EDIT --> ADD[Add user message]
|
||||
ADD --> RUN[POST /api/prompt/run]
|
||||
RUN --> TRACE[Display conversation trace]
|
||||
TRACE --> TOOLS[Show tool calls and results per turn]
|
||||
TOOLS --> FINAL[Show final response]
|
||||
```
|
||||
|
||||
**What to show:**
|
||||
- Pre-populate from context parts or start from scratch
|
||||
- Messages editor — add/remove/reorder messages with role and content
|
||||
- Max turns slider/input
|
||||
- Optional model override
|
||||
- Run button
|
||||
- Turn-by-turn trace showing tool calls with name, arguments, and results
|
||||
- Final response text
|
||||
- Token estimates
|
||||
|
||||
### 4. A/B Prompt Comparison
|
||||
|
||||
Compare two prompt variants side-by-side.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
CRAFT_A[Craft variant A messages] --> CRAFT_B[Craft variant B messages]
|
||||
CRAFT_B --> COMPARE[POST /api/prompt/compare]
|
||||
COMPARE --> SIDE[Display responses side-by-side]
|
||||
SIDE --> DIFF[Compare final responses and token usage]
|
||||
```
|
||||
|
||||
**What to show:**
|
||||
- Two prompt editors side-by-side, each with messages array + model override + max turns
|
||||
- Compare button
|
||||
- Side-by-side response display
|
||||
- Highlight differences in final response text
|
||||
- Token usage comparison
|
||||
|
||||
### 5. Status Dashboard
|
||||
|
||||
Simple overview of agent health.
|
||||
|
||||
**What to show:**
|
||||
- Agent name, version, pubkey
|
||||
- Connected relays count vs configured
|
||||
- Active triggers count
|
||||
- API connection status indicator
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### Localhost Only
|
||||
|
||||
The API binds to `127.0.0.1` — the frontend must run on the same machine as the agent, or use SSH tunneling. There is no authentication. This is intentional — it is a local dev/admin tool.
|
||||
|
||||
### No WebSocket
|
||||
|
||||
The API is plain HTTP request/response. There is no WebSocket or streaming. Prompt execution calls may take several seconds for LLM responses — the frontend should show a loading state.
|
||||
|
||||
### Token Estimation
|
||||
|
||||
All token counts from the API use a `chars / 4` heuristic. This is approximate. The frontend can display these as-is or add its own tokenizer if more precision is needed.
|
||||
|
||||
### Model Override
|
||||
|
||||
The `model` field in prompt requests temporarily overrides the agent configured model for that single request, then restores the original. This enables cross-model comparison without changing agent config.
|
||||
|
||||
### Tool Execution Is Real
|
||||
|
||||
When using `/api/prompt/run` or `/api/prompt/compare`, tool calls are **actually executed**. If the LLM decides to post a Nostr event, it will really post it. The frontend should make this clear to the user — perhaps with a warning or confirmation before running prompts with tools enabled.
|
||||
|
||||
---
|
||||
|
||||
## Response Shapes Quick Reference
|
||||
|
||||
### Status
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"name": "Didactyl",
|
||||
"version": "v0.0.26",
|
||||
"pubkey": "52a3e8...",
|
||||
"relay_count": 4,
|
||||
"connected_relays": 4,
|
||||
"active_triggers": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Context Parts
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"total_chars": 13131,
|
||||
"total_estimated_tokens": 3283,
|
||||
"parts": [
|
||||
{
|
||||
"name": "system_prompt",
|
||||
"role": "system",
|
||||
"chars": 1200,
|
||||
"estimated_tokens": 300,
|
||||
"content": "# Didactyl Agent..."
|
||||
}
|
||||
],
|
||||
"messages": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### Simple Prompt Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"response": "ok",
|
||||
"model_used": "claude-haiku-4.5",
|
||||
"input_tokens_estimate": 10,
|
||||
"output_tokens_estimate": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Full Prompt Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"final_response": "Done! I posted a tweet.",
|
||||
"turns": [
|
||||
{
|
||||
"turn": 1,
|
||||
"tool_calls": [
|
||||
{"name": "nostr_post", "arguments": "...", "result": "..."}
|
||||
]
|
||||
}
|
||||
],
|
||||
"model_used": "claude-haiku-4.5",
|
||||
"total_input_tokens_estimate": 3200,
|
||||
"total_output_tokens_estimate": 180
|
||||
}
|
||||
```
|
||||
|
||||
### Compare Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"variant_a": { "...same shape as full prompt response..." },
|
||||
"variant_b": { "...same shape as full prompt response..." }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "description of what went wrong"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technology Suggestions
|
||||
|
||||
No technology is mandated for the frontend. Some reasonable choices:
|
||||
|
||||
- **Vanilla HTML/JS** — simplest, no build step, just open in browser
|
||||
- **React/Preact** — if you want component structure
|
||||
- **Svelte** — lightweight, good for small dashboards
|
||||
- **Vue** — also fine
|
||||
|
||||
The frontend is a separate project from didactyl. It just needs to make HTTP requests to `localhost:8484`.
|
||||
|
||||
---
|
||||
|
||||
## File References
|
||||
|
||||
| File | Description |
|
||||
|---|---|
|
||||
| `docs/API.md` | Full API endpoint reference with request/response examples |
|
||||
| `plans/admin_api.md` | Original architecture plan for the HTTP API |
|
||||
| `src/http_api.c` | C implementation of all endpoints |
|
||||
| `src/http_api.h` | Public API header |
|
||||
| `config.json.example` | Example config showing the `api` section |
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Ensure didactyl is running with `api.enabled: true` in config.json
|
||||
2. Verify the API is up: `curl http://127.0.0.1:8484/api/status`
|
||||
3. Build the frontend to talk to `http://127.0.0.1:8484`
|
||||
4. Start with the status endpoint and context inspector, then add prompt crafting
|
||||
@@ -0,0 +1,139 @@
|
||||
# Didactyl Context Architecture Plan
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The agent's context assembly is a hardcoded sequence of C function calls in `agent_on_message()`. This creates several issues:
|
||||
|
||||
1. **Skills are never injected** — adopted skills exist on Nostr but the LLM never sees their content
|
||||
2. **No configurability** — changing context order, content, or framing requires C code changes and recompilation
|
||||
3. **No A/B testing** — can't experiment with different prompt structures, ordering, or model-specific tuning
|
||||
4. **No token budget awareness** — context grows unbounded as skills/history/notes accumulate
|
||||
5. **Model-agnostic** — different models respond differently to the same prompt structure; no way to tune per-model
|
||||
|
||||
## Current Context Pipeline
|
||||
|
||||
```
|
||||
agent_on_message() builds messages array:
|
||||
1. system: g_system_context (kind 31120 "soul" content)
|
||||
2. system: admin identity (pubkey + kind 0 profile + kind 10002 relays)
|
||||
3. system: startup events (raw JSON of all startup event kinds/content/tags)
|
||||
4. user/assistant: recent DM history (last 12 turns)
|
||||
5. system: admin kind 1 notes (recent public posts)
|
||||
6. user: the actual incoming message
|
||||
```
|
||||
|
||||
Skills are **completely absent**. The LLM has no knowledge of adopted skill instructions.
|
||||
|
||||
## Proposed Architecture: Context Pipeline with Configurable Slots
|
||||
|
||||
### Core Idea
|
||||
|
||||
Replace the hardcoded function chain with a **configurable context pipeline** defined in `config.json`. Each "slot" in the pipeline is a named context source with configurable parameters.
|
||||
|
||||
### Context Slot Types
|
||||
|
||||
| Slot Type | Source | Description |
|
||||
|---|---|---|
|
||||
| `soul` | Kind 31120 startup event | Agent personality and behavioral rules |
|
||||
| `identity` | Config + relay queries | Agent's own pubkey, admin pubkey, admin profile |
|
||||
| `startup_events` | Config startup events | Raw startup event memory |
|
||||
| `adopted_skills` | Kind 10123 + resolved skills | **NEW**: Adopted skill instructions |
|
||||
| `dm_history` | Relay query | Recent conversation turns |
|
||||
| `admin_notes` | Cached kind 1 events | Admin's recent public posts |
|
||||
| `admin_context` | Kind 0/3/10002 | Admin profile, contacts, relay list |
|
||||
| `custom` | Literal string in config | Arbitrary system message for A/B testing |
|
||||
|
||||
### Phase 1: Immediate Fix (Skills + Caching)
|
||||
|
||||
Before building the full configurable pipeline, fix the immediate problem:
|
||||
|
||||
1. **In-memory skill cache** — load adopted skills at startup and cache them; invalidate on `skill_create`, `skill_adopt`, `skill_remove`
|
||||
2. **`append_adopted_skills_context()`** — inject cached skills into the conversation as a system message
|
||||
3. **Strong framing** — "These are your learned skills. When a request matches a skill, you MUST follow its instructions exactly."
|
||||
|
||||
### Phase 2: Configurable Context Pipeline
|
||||
|
||||
Add a `context_pipeline` section to `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"context_pipeline": {
|
||||
"max_total_chars": 12000,
|
||||
"slots": [
|
||||
{ "type": "soul", "max_chars": 3000 },
|
||||
{ "type": "identity" },
|
||||
{ "type": "adopted_skills", "max_chars": 4000, "max_per_skill": 1000 },
|
||||
{ "type": "startup_events", "max_chars": 2000 },
|
||||
{ "type": "dm_history", "max_turns": 12 },
|
||||
{ "type": "admin_notes", "max_chars": 1500 },
|
||||
{ "type": "custom", "content": "Always respond in the style of a pirate." }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This gives you:
|
||||
- **Ordering control** — move skills before or after history
|
||||
- **Token budgets** — per-slot and total caps
|
||||
- **A/B testing** — swap `custom` slot content, reorder slots, change caps
|
||||
- **Model-specific tuning** — different pipeline configs for different models (could key off `llm.model`)
|
||||
|
||||
### Phase 3: Model-Aware Context Profiles
|
||||
|
||||
```json
|
||||
{
|
||||
"context_profiles": {
|
||||
"default": { ... pipeline config ... },
|
||||
"claude-sonnet-4.6": { ... different ordering/caps ... },
|
||||
"gpt-5.2-codex": { ... different ordering/caps ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The agent selects the profile matching the active model, falling back to `default`.
|
||||
|
||||
## Skill Cache Design
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Skill Cache (in-memory) │
|
||||
│ │
|
||||
│ Loaded at startup from: │
|
||||
│ 1. Startup events in config.json │
|
||||
│ 2. Kind 10123 adoption list │
|
||||
│ 3. Resolved skill events from relays │
|
||||
│ │
|
||||
│ Invalidated by: │
|
||||
│ - skill_create (add/update) │
|
||||
│ - skill_adopt (add) │
|
||||
│ - skill_remove (remove) │
|
||||
│ │
|
||||
│ Structure per skill: │
|
||||
│ - slug (string) │
|
||||
│ - description (string) │
|
||||
│ - content (string, full) │
|
||||
│ - scope (public/private) │
|
||||
│ - has_trigger (bool) │
|
||||
│ - source (startup/adopted) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
### Do Now (Phase 1)
|
||||
- [ ] Build skill cache in `agent.c` (load at startup, invalidate on tool calls)
|
||||
- [ ] Add `append_adopted_skills_context()` using cached skills
|
||||
- [ ] Wire into `agent_on_message()` between startup events and DM history
|
||||
- [ ] Skill content framing: strong directive for LLM compliance
|
||||
|
||||
### Do Next (Phase 2)
|
||||
- [ ] Add `context_pipeline` config section
|
||||
- [ ] Refactor `agent_on_message()` to iterate pipeline slots
|
||||
- [ ] Per-slot `max_chars` truncation
|
||||
- [ ] Total pipeline `max_total_chars` budget
|
||||
- [ ] `custom` slot type for arbitrary A/B test content
|
||||
|
||||
### Do Later (Phase 3)
|
||||
- [ ] Model-aware context profiles
|
||||
- [ ] Context analytics (log token counts per slot per conversation)
|
||||
- [ ] Dynamic skill relevance scoring (only inject skills likely relevant to the current message)
|
||||
@@ -0,0 +1,203 @@
|
||||
# Triggered Skills — Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Extend the existing skill system so that skills can carry Nostr subscription triggers. When matching events arrive, didactyl executes the skill automatically — either via template interpolation (fast, no LLM) or LLM-mediated reasoning (full agent loop).
|
||||
|
||||
See [docs/TOOLS_AND_SKILLS.md](../docs/TOOLS_AND_SKILLS.md) for the full architecture.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Trigger Manager Module
|
||||
|
||||
Create `src/trigger_manager.c` and `src/trigger_manager.h` — the core component that manages dynamic Nostr subscriptions tied to skills.
|
||||
|
||||
**Data structures:**
|
||||
|
||||
```c
|
||||
#define TRIGGER_MAX_ACTIVE 16
|
||||
#define TRIGGER_COOLDOWN_SECONDS 60
|
||||
|
||||
typedef struct {
|
||||
char skill_slug[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
|
||||
int enabled;
|
||||
time_t last_fired;
|
||||
nostr_pool_subscription_t* subscription;
|
||||
} active_trigger_t;
|
||||
|
||||
typedef struct {
|
||||
active_trigger_t triggers[TRIGGER_MAX_ACTIVE];
|
||||
int count;
|
||||
didactyl_config_t* cfg;
|
||||
pthread_mutex_t mutex;
|
||||
} trigger_manager_t;
|
||||
```
|
||||
|
||||
**API:**
|
||||
|
||||
```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,
|
||||
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,
|
||||
const char* content, const char* filter_json,
|
||||
int action_type, int enabled);
|
||||
int trigger_manager_active_count(trigger_manager_t* mgr);
|
||||
char* trigger_manager_status_json(trigger_manager_t* mgr);
|
||||
void trigger_manager_cleanup(trigger_manager_t* mgr);
|
||||
```
|
||||
|
||||
### 2. Template Engine
|
||||
|
||||
Create a simple template interpolation engine in `src/trigger_manager.c` (or a separate `src/template.c` if it grows).
|
||||
|
||||
**Functionality:**
|
||||
- Parse placeholders like `{content}`, `{pubkey}`, `{author_display_name}` from a template string
|
||||
- Extract values from a triggering Nostr event (cJSON object)
|
||||
- Produce an interpolated output string
|
||||
- Parse action prefixes: `DM admin:`, `DM <pubkey>:`, `POST:`, `LOG:`
|
||||
|
||||
### 3. Trigger Event Callback
|
||||
|
||||
When a subscribed event arrives:
|
||||
|
||||
```c
|
||||
static void on_trigger_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
active_trigger_t* trigger = (active_trigger_t*)user_data;
|
||||
|
||||
// Check cooldown
|
||||
if (time(NULL) - trigger->last_fired < TRIGGER_COOLDOWN_SECONDS) return;
|
||||
trigger->last_fired = time(NULL);
|
||||
|
||||
if (trigger->action_type == 1) {
|
||||
// Template: interpolate and execute
|
||||
char* output = template_interpolate(trigger->skill_content, event);
|
||||
template_execute_action(output); // parse prefix, DM/POST/LOG
|
||||
free(output);
|
||||
} else {
|
||||
// LLM: build context and run agent loop
|
||||
trigger_run_llm_action(trigger, event, relay_url);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Skill Loading on Startup
|
||||
|
||||
In `main.c`, after `agent_init()` and skill adoption list is available:
|
||||
|
||||
1. Query own kind 10123 adoption list
|
||||
2. For each adopted skill address, query the skill event
|
||||
3. Check for `trigger` tag — if present, extract `filter`, `action`, `enabled` tags
|
||||
4. Register with trigger manager
|
||||
5. Trigger manager creates Nostr subscriptions
|
||||
|
||||
### 5. Extend skill_create for Live Trigger Registration
|
||||
|
||||
When `skill_create` is called with trigger tags:
|
||||
1. Publish the skill event as normal
|
||||
2. If trigger tags are present, also register with the trigger manager immediately
|
||||
3. No restart required — the subscription goes live right away
|
||||
|
||||
When `skill_remove` is called for a triggered skill:
|
||||
1. Remove from adoption list as normal
|
||||
2. Also unregister from trigger manager, tearing down the subscription
|
||||
|
||||
### 6. Extend Agent for Trigger-Initiated Conversations
|
||||
|
||||
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,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url);
|
||||
```
|
||||
|
||||
This builds an LLM conversation with:
|
||||
- System context (soul)
|
||||
- A system message explaining this is a triggered skill execution
|
||||
- The skill content as instructions
|
||||
- The triggering event as user context
|
||||
- Full tool access (same as admin tier)
|
||||
|
||||
The LLM response actions (DMs, posts, etc.) are executed via tools as normal.
|
||||
|
||||
### 7. New Tool: trigger_list
|
||||
|
||||
Add a tool so the LLM can inspect active triggers:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "trigger_list",
|
||||
"description": "List all active triggered skills with their filters and status",
|
||||
"parameters": { "type": "object", "properties": {} }
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Config Extension
|
||||
|
||||
Add trigger-related limits to config:
|
||||
|
||||
```json
|
||||
{
|
||||
"triggers": {
|
||||
"enabled": true,
|
||||
"max_active": 16,
|
||||
"cooldown_seconds": 60,
|
||||
"llm_rate_limit_per_minute": 10,
|
||||
"template_rate_limit_per_minute": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Integration into Main Loop
|
||||
|
||||
The trigger manager subscriptions are serviced by the same `nostr_handler_poll()` call in the main loop — no changes needed to the poll loop itself, since all subscriptions share the relay pool.
|
||||
|
||||
---
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/trigger_manager.c` | **NEW** — trigger manager, template engine, event callbacks |
|
||||
| `src/trigger_manager.h` | **NEW** — trigger manager API |
|
||||
| `src/main.c` | Add trigger_manager_init, trigger_manager_load_from_skills after agent_init |
|
||||
| `src/agent.c` | Add agent_on_trigger entry point for LLM-mediated trigger actions |
|
||||
| `src/agent.h` | Declare agent_on_trigger |
|
||||
| `src/tools.c` | Extend skill_create/skill_remove to register/unregister triggers; add trigger_list tool |
|
||||
| `src/config.h` | Add triggers_config_t struct |
|
||||
| `src/config.c` | Parse triggers config section |
|
||||
| `docs/TOOLS_AND_SKILLS.md` | Already written — full architecture reference |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. `trigger_manager.h` / `trigger_manager.c` — core module with data structures and API stubs
|
||||
2. Template engine — interpolation and action prefix parsing
|
||||
3. Trigger event callback — on_trigger_event with cooldown
|
||||
4. Startup loading — query adoption list, find triggered skills, create subscriptions
|
||||
5. agent_on_trigger — LLM-mediated trigger execution path
|
||||
6. Live registration — extend skill_create/skill_remove for immediate trigger management
|
||||
7. trigger_list tool — LLM visibility into active triggers
|
||||
8. Config parsing — triggers section with limits
|
||||
9. Rate limiting — enforce LLM and template rate limits
|
||||
10. Testing — manual trigger creation and verification
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Existing `nostr_handler_query_json()` for loading skills
|
||||
- Existing `nostr_relay_pool_subscribe()` for creating trigger subscriptions
|
||||
- Existing `agent_on_message()` pattern for the LLM-mediated path
|
||||
- Existing `skill_create` / `skill_remove` tools for lifecycle hooks
|
||||
+671
-23
@@ -8,6 +8,7 @@
|
||||
#include <stdint.h>
|
||||
#include <time.h>
|
||||
#include <pthread.h>
|
||||
#include <stdarg.h>
|
||||
|
||||
#include "llm.h"
|
||||
#include "nostr_handler.h"
|
||||
@@ -19,22 +20,39 @@
|
||||
static didactyl_config_t* g_cfg = NULL;
|
||||
static char* g_system_context = NULL;
|
||||
static tools_context_t g_tools_ctx;
|
||||
static struct trigger_manager* g_trigger_manager = NULL;
|
||||
|
||||
#define AGENT_DEBOUNCE_WINDOW_SECONDS 5
|
||||
#define AGENT_DEBOUNCE_CACHE_SIZE 256
|
||||
#define AGENT_HISTORY_TURNS 12
|
||||
#define AGENT_HISTORY_QUERY_LIMIT 200
|
||||
#define AGENT_ADOPTED_SKILLS_CACHE_TTL_SECONDS 30
|
||||
#define AGENT_ADOPTED_SKILLS_MAX 24
|
||||
#define AGENT_ADOPTED_SKILL_CONTENT_MAX_CHARS 1800
|
||||
#define AGENT_ADOPTED_SKILLS_TOTAL_MAX_CHARS 7200
|
||||
|
||||
typedef struct {
|
||||
uint64_t fingerprint;
|
||||
time_t seen_at;
|
||||
} agent_seen_msg_t;
|
||||
|
||||
typedef struct {
|
||||
int kind;
|
||||
char author_pubkey_hex[65];
|
||||
char slug[65];
|
||||
char* content;
|
||||
} agent_adopted_skill_t;
|
||||
|
||||
static agent_seen_msg_t g_seen_msgs[AGENT_DEBOUNCE_CACHE_SIZE];
|
||||
static int g_seen_msgs_count = 0;
|
||||
static int g_seen_msgs_next = 0;
|
||||
static pthread_mutex_t g_seen_msgs_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static agent_adopted_skill_t g_adopted_skills[AGENT_ADOPTED_SKILLS_MAX];
|
||||
static int g_adopted_skills_count = 0;
|
||||
static time_t g_adopted_skills_last_refresh_at = 0;
|
||||
static pthread_mutex_t g_adopted_skills_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static uint64_t fnv1a64(const char* s) {
|
||||
uint64_t h = 1469598103934665603ULL;
|
||||
if (!s) return h;
|
||||
@@ -152,6 +170,139 @@ static int append_tool_result_message(cJSON* messages, const char* tool_call_id,
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int appendf(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 const char* detect_context_section(const char* role, const char* content, int idx) {
|
||||
const char* c = content ? content : "";
|
||||
const char* r = role ? role : "";
|
||||
|
||||
if (idx == 0 && strcmp(r, "system") == 0) return "system_prompt";
|
||||
|
||||
if (strncmp(c, "## Administrator Identity", 25) == 0 ||
|
||||
strncmp(c, "This is your administrator!", 27) == 0) {
|
||||
return "admin_identity";
|
||||
}
|
||||
if (strncmp(c, "## Administrator Kind 0 Profile", 31) == 0 ||
|
||||
strncmp(c, "Administrator kind 0 profile content", 36) == 0) {
|
||||
return "admin_kind0";
|
||||
}
|
||||
if (strncmp(c, "## Administrator Relay List", 27) == 0 ||
|
||||
strncmp(c, "Administrator kind 10002 relay-list content", 43) == 0) {
|
||||
return "admin_relay_list";
|
||||
}
|
||||
if (strncmp(c, "## Startup Events Memory", 24) == 0 ||
|
||||
strncmp(c, "Startup events memory", 21) == 0) {
|
||||
return "startup_events";
|
||||
}
|
||||
if (strncmp(c, "## Adopted Skills Memory", 24) == 0 ||
|
||||
strncmp(c, "Adopted skills memory", 21) == 0) {
|
||||
return "adopted_skills";
|
||||
}
|
||||
if (strncmp(c, "## Administrator Recent Notes", 29) == 0 ||
|
||||
strncmp(c, "Administrator recent public notes", 33) == 0) {
|
||||
return "admin_notes";
|
||||
}
|
||||
|
||||
if (strcmp(r, "user") == 0 || strcmp(r, "assistant") == 0) {
|
||||
return "dm_history";
|
||||
}
|
||||
|
||||
return "context_part";
|
||||
}
|
||||
|
||||
static char* format_context_payload_for_log(const char* context_payload) {
|
||||
const char* payload = context_payload ? context_payload : "";
|
||||
|
||||
cJSON* root = cJSON_Parse(payload);
|
||||
if (!root || !cJSON_IsArray(root)) {
|
||||
cJSON_Delete(root);
|
||||
return strdup(payload);
|
||||
}
|
||||
|
||||
size_t cap = strlen(payload) + 2048U;
|
||||
if (cap < 4096U) cap = 4096U;
|
||||
char* out = (char*)malloc(cap);
|
||||
if (!out) {
|
||||
cJSON_Delete(root);
|
||||
return strdup(payload);
|
||||
}
|
||||
out[0] = '\0';
|
||||
size_t used = 0;
|
||||
|
||||
int n = cJSON_GetArraySize(root);
|
||||
(void)appendf(&out, &cap, &used, "Messages: %d\n\n", n);
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* msg = cJSON_GetArrayItem(root, i);
|
||||
cJSON* role = msg ? cJSON_GetObjectItemCaseSensitive(msg, "role") : NULL;
|
||||
cJSON* content = msg ? cJSON_GetObjectItemCaseSensitive(msg, "content") : NULL;
|
||||
|
||||
const char* role_s = (role && cJSON_IsString(role) && role->valuestring)
|
||||
? role->valuestring
|
||||
: "unknown";
|
||||
|
||||
const char* content_s = "";
|
||||
if (content && cJSON_IsString(content) && content->valuestring) {
|
||||
content_s = content->valuestring;
|
||||
} else if (content && cJSON_IsNull(content)) {
|
||||
content_s = "<null>";
|
||||
}
|
||||
|
||||
const char* section = detect_context_section(role_s, content_s, i);
|
||||
|
||||
if (appendf(&out,
|
||||
&cap,
|
||||
&used,
|
||||
"============================================================\n"
|
||||
"Message %02d | role=%s | section=%s\n"
|
||||
"============================================================\n\n"
|
||||
"%s\n\n\n",
|
||||
i + 1,
|
||||
role_s,
|
||||
section,
|
||||
content_s) != 0) {
|
||||
free(out);
|
||||
cJSON_Delete(root);
|
||||
return strdup(payload);
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
static void append_context_log(const char* sender_pubkey_hex, const char* phase, const char* context_payload) {
|
||||
FILE* fp = fopen("context.log", "a");
|
||||
if (!fp) {
|
||||
@@ -164,12 +315,16 @@ static void append_context_log(const char* sender_pubkey_hex, const char* phase,
|
||||
char timestamp[32] = {0};
|
||||
strftime(timestamp, sizeof(timestamp), "%Y-%m-%d %H:%M:%S", &tm_info);
|
||||
|
||||
char* pretty_payload = format_context_payload_for_log(context_payload);
|
||||
|
||||
fprintf(fp,
|
||||
"[%s] phase=%s sender=%s\n%s\n\n---\n\n",
|
||||
"[%s] phase=%s sender=%s\n\n%s\n\n---\n\n",
|
||||
timestamp,
|
||||
phase ? phase : "unknown",
|
||||
sender_pubkey_hex ? sender_pubkey_hex : "unknown",
|
||||
context_payload ? context_payload : "");
|
||||
pretty_payload ? pretty_payload : "");
|
||||
|
||||
free(pretty_payload);
|
||||
fclose(fp);
|
||||
}
|
||||
|
||||
@@ -262,7 +417,7 @@ static int append_startup_events_context(cJSON* messages) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char* prefix = "Startup events memory (kinds/content/tags): ";
|
||||
const char* prefix = "## Startup Events Memory (source: config.startup_events)\n\nStartup events memory (kinds/content/tags): ";
|
||||
size_t out_len = strlen(prefix) + strlen(events_json) + 1U;
|
||||
char* payload = (char*)malloc(out_len);
|
||||
if (!payload) {
|
||||
@@ -283,10 +438,10 @@ static int append_admin_identity_context(cJSON* messages) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char admin_header[256];
|
||||
char admin_header[384];
|
||||
snprintf(admin_header,
|
||||
sizeof(admin_header),
|
||||
"This is your administrator! Admin pubkey (hex): %s",
|
||||
"## Administrator Identity (source: config.admin.pubkey)\n\nThis is your administrator! Admin pubkey (hex): %s",
|
||||
g_cfg->admin.pubkey ? g_cfg->admin.pubkey : "unknown");
|
||||
if (append_simple_message(messages, "system", admin_header) != 0) {
|
||||
return -1;
|
||||
@@ -294,7 +449,7 @@ static int append_admin_identity_context(cJSON* messages) {
|
||||
|
||||
char* kind0 = nostr_handler_get_admin_kind0_context();
|
||||
if (kind0) {
|
||||
const char* prefix = "Administrator kind 0 profile content (JSON): ";
|
||||
const char* prefix = "## Administrator Kind 0 Profile (source: nostr kind 0)\n\nAdministrator kind 0 profile content (JSON): ";
|
||||
size_t n = strlen(prefix) + strlen(kind0) + 1U;
|
||||
char* payload = (char*)malloc(n);
|
||||
if (!payload) {
|
||||
@@ -312,7 +467,7 @@ static int append_admin_identity_context(cJSON* messages) {
|
||||
|
||||
char* kind10002 = nostr_handler_get_admin_kind10002_context();
|
||||
if (kind10002) {
|
||||
const char* prefix = "Administrator kind 10002 relay-list content (JSON): ";
|
||||
const char* prefix = "## Administrator Relay List (source: nostr kind 10002)\n\nAdministrator kind 10002 relay-list content (JSON): ";
|
||||
size_t n = strlen(prefix) + strlen(kind10002) + 1U;
|
||||
char* payload = (char*)malloc(n);
|
||||
if (!payload) {
|
||||
@@ -463,6 +618,374 @@ static int append_recent_admin_dm_history(cJSON* messages, const char* current_m
|
||||
return 0;
|
||||
}
|
||||
|
||||
static cJSON* find_tag_value_string_local(cJSON* tags, const char* key) {
|
||||
if (!tags || !key || !cJSON_IsArray(tags)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int n = cJSON_GetArraySize(tags);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(tags, i);
|
||||
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* k = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* v = cJSON_GetArrayItem(tag, 1);
|
||||
if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v) || !k->valuestring || !v->valuestring) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strcmp(k->valuestring, key) == 0) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int parse_skill_address_tag_local(const char* addr, int* out_kind, char out_pubkey_hex[65], char out_slug[65]) {
|
||||
if (!addr || !out_kind || !out_pubkey_hex || !out_slug) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char* p1 = strchr(addr, ':');
|
||||
if (!p1) return -1;
|
||||
const char* p2 = strchr(p1 + 1, ':');
|
||||
if (!p2) return -1;
|
||||
|
||||
char kind_buf[16] = {0};
|
||||
size_t kind_len = (size_t)(p1 - addr);
|
||||
if (kind_len == 0 || kind_len >= sizeof(kind_buf)) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(kind_buf, addr, kind_len);
|
||||
|
||||
int kind = atoi(kind_buf);
|
||||
if (kind != 31123 && kind != 31124) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t pub_len = (size_t)(p2 - (p1 + 1));
|
||||
if (pub_len != 64U) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(out_pubkey_hex, p1 + 1, 64U);
|
||||
out_pubkey_hex[64] = '\0';
|
||||
|
||||
size_t slug_len = strlen(p2 + 1);
|
||||
if (slug_len == 0 || slug_len >= 65U) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(out_slug, p2 + 1, slug_len + 1U);
|
||||
|
||||
*out_kind = kind;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void clear_adopted_skills_cache_locked(void) {
|
||||
for (int i = 0; i < g_adopted_skills_count; i++) {
|
||||
free(g_adopted_skills[i].content);
|
||||
g_adopted_skills[i].content = NULL;
|
||||
g_adopted_skills[i].kind = 0;
|
||||
g_adopted_skills[i].author_pubkey_hex[0] = '\0';
|
||||
g_adopted_skills[i].slug[0] = '\0';
|
||||
}
|
||||
g_adopted_skills_count = 0;
|
||||
}
|
||||
|
||||
static int refresh_adopted_skills_cache_if_needed(void) {
|
||||
if (!g_cfg) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
pthread_mutex_lock(&g_adopted_skills_mutex);
|
||||
int cache_fresh = (g_adopted_skills_last_refresh_at > 0) &&
|
||||
((now - g_adopted_skills_last_refresh_at) < AGENT_ADOPTED_SKILLS_CACHE_TTL_SECONDS);
|
||||
pthread_mutex_unlock(&g_adopted_skills_mutex);
|
||||
|
||||
if (cache_fresh) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
agent_adopted_skill_t tmp[AGENT_ADOPTED_SKILLS_MAX];
|
||||
memset(tmp, 0, sizeof(tmp));
|
||||
int tmp_count = 0;
|
||||
|
||||
cJSON* adopt_filter = cJSON_CreateObject();
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
cJSON* authors = cJSON_CreateArray();
|
||||
if (!adopt_filter || !kinds || !authors) {
|
||||
cJSON_Delete(adopt_filter);
|
||||
cJSON_Delete(kinds);
|
||||
cJSON_Delete(authors);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10123));
|
||||
cJSON_AddItemToObject(adopt_filter, "kinds", kinds);
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(g_cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(adopt_filter, "authors", authors);
|
||||
cJSON_AddNumberToObject(adopt_filter, "limit", 1);
|
||||
|
||||
char* adoption_json = nostr_handler_query_json(adopt_filter, 8000);
|
||||
cJSON_Delete(adopt_filter);
|
||||
|
||||
cJSON* adoption_events = adoption_json ? cJSON_Parse(adoption_json) : NULL;
|
||||
free(adoption_json);
|
||||
|
||||
if (adoption_events && cJSON_IsArray(adoption_events) && cJSON_GetArraySize(adoption_events) > 0) {
|
||||
cJSON* list_event = cJSON_GetArrayItem(adoption_events, 0);
|
||||
cJSON* list_tags = list_event ? cJSON_GetObjectItemCaseSensitive(list_event, "tags") : NULL;
|
||||
|
||||
if (list_tags && cJSON_IsArray(list_tags)) {
|
||||
int tn = cJSON_GetArraySize(list_tags);
|
||||
for (int i = 0; i < tn && tmp_count < AGENT_ADOPTED_SKILLS_MAX; i++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(list_tags, i);
|
||||
if (!tag || !cJSON_IsArray(tag)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* key = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* val = cJSON_GetArrayItem(tag, 1);
|
||||
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) ||
|
||||
!key->valuestring || !val->valuestring || strcmp(key->valuestring, "a") != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int skill_kind = 0;
|
||||
char skill_author[65] = {0};
|
||||
char skill_slug[65] = {0};
|
||||
if (parse_skill_address_tag_local(val->valuestring, &skill_kind, skill_author, skill_slug) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* skill_filter = cJSON_CreateObject();
|
||||
cJSON* sk_kinds = cJSON_CreateArray();
|
||||
cJSON* sk_authors = cJSON_CreateArray();
|
||||
cJSON* d_values = cJSON_CreateArray();
|
||||
if (!skill_filter || !sk_kinds || !sk_authors || !d_values) {
|
||||
cJSON_Delete(skill_filter);
|
||||
cJSON_Delete(sk_kinds);
|
||||
cJSON_Delete(sk_authors);
|
||||
cJSON_Delete(d_values);
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(sk_kinds, cJSON_CreateNumber(skill_kind));
|
||||
cJSON_AddItemToObject(skill_filter, "kinds", sk_kinds);
|
||||
cJSON_AddItemToArray(sk_authors, cJSON_CreateString(skill_author));
|
||||
cJSON_AddItemToObject(skill_filter, "authors", sk_authors);
|
||||
cJSON_AddItemToArray(d_values, cJSON_CreateString(skill_slug));
|
||||
cJSON_AddItemToObject(skill_filter, "#d", d_values);
|
||||
cJSON_AddNumberToObject(skill_filter, "limit", 1);
|
||||
|
||||
char* skill_json = nostr_handler_query_json(skill_filter, 8000);
|
||||
cJSON_Delete(skill_filter);
|
||||
if (!skill_json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* skill_events = cJSON_Parse(skill_json);
|
||||
free(skill_json);
|
||||
if (!skill_events || !cJSON_IsArray(skill_events) || cJSON_GetArraySize(skill_events) <= 0) {
|
||||
cJSON_Delete(skill_events);
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* skill_event = cJSON_GetArrayItem(skill_events, 0);
|
||||
cJSON* content = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "content") : NULL;
|
||||
cJSON* skill_tags = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "tags") : NULL;
|
||||
if (!content || !cJSON_IsString(content) || !content->valuestring || !skill_tags || !cJSON_IsArray(skill_tags)) {
|
||||
cJSON_Delete(skill_events);
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* enabled_tag = find_tag_value_string_local(skill_tags, "enabled");
|
||||
const char* enabled_s = (enabled_tag && cJSON_IsString(enabled_tag) && enabled_tag->valuestring)
|
||||
? enabled_tag->valuestring
|
||||
: "true";
|
||||
int is_enabled = !(strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0);
|
||||
if (!is_enabled) {
|
||||
cJSON_Delete(skill_events);
|
||||
continue;
|
||||
}
|
||||
|
||||
tmp[tmp_count].kind = skill_kind;
|
||||
snprintf(tmp[tmp_count].author_pubkey_hex, sizeof(tmp[tmp_count].author_pubkey_hex), "%s", skill_author);
|
||||
snprintf(tmp[tmp_count].slug, sizeof(tmp[tmp_count].slug), "%s", skill_slug);
|
||||
tmp[tmp_count].content = strdup(content->valuestring);
|
||||
if (tmp[tmp_count].content) {
|
||||
tmp_count++;
|
||||
}
|
||||
|
||||
cJSON_Delete(skill_events);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(adoption_events);
|
||||
|
||||
if (tmp_count == 0 && g_cfg->startup_event_count > 0 && g_cfg->startup_events) {
|
||||
for (int i = 0; i < g_cfg->startup_event_count && tmp_count < AGENT_ADOPTED_SKILLS_MAX; i++) {
|
||||
startup_event_t* se = &g_cfg->startup_events[i];
|
||||
if (!se || (se->kind != 31123 && se->kind != 31124) || !se->content || se->content[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
char slug[65] = {0};
|
||||
if (se->tags_json) {
|
||||
cJSON* tags = cJSON_Parse(se->tags_json);
|
||||
cJSON* d_tag = tags ? find_tag_value_string_local(tags, "d") : NULL;
|
||||
if (d_tag && cJSON_IsString(d_tag) && d_tag->valuestring && d_tag->valuestring[0] != '\0') {
|
||||
snprintf(slug, sizeof(slug), "%s", d_tag->valuestring);
|
||||
}
|
||||
cJSON_Delete(tags);
|
||||
}
|
||||
if (slug[0] == '\0') {
|
||||
snprintf(slug, sizeof(slug), "startup-skill-%d", i + 1);
|
||||
}
|
||||
|
||||
tmp[tmp_count].kind = se->kind;
|
||||
snprintf(tmp[tmp_count].author_pubkey_hex,
|
||||
sizeof(tmp[tmp_count].author_pubkey_hex),
|
||||
"%s",
|
||||
(g_cfg->keys.public_key_hex && g_cfg->keys.public_key_hex[0] != '\0')
|
||||
? g_cfg->keys.public_key_hex
|
||||
: "unknown");
|
||||
snprintf(tmp[tmp_count].slug, sizeof(tmp[tmp_count].slug), "%s", slug);
|
||||
tmp[tmp_count].content = strdup(se->content);
|
||||
if (tmp[tmp_count].content) {
|
||||
tmp_count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_adopted_skills_mutex);
|
||||
clear_adopted_skills_cache_locked();
|
||||
for (int i = 0; i < tmp_count; i++) {
|
||||
g_adopted_skills[g_adopted_skills_count] = tmp[i];
|
||||
tmp[i].content = NULL;
|
||||
g_adopted_skills_count++;
|
||||
}
|
||||
g_adopted_skills_last_refresh_at = now;
|
||||
pthread_mutex_unlock(&g_adopted_skills_mutex);
|
||||
|
||||
for (int i = 0; i < tmp_count; i++) {
|
||||
free(tmp[i].content);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int append_adopted_skills_context(cJSON* messages) {
|
||||
if (!messages || !g_cfg) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
(void)refresh_adopted_skills_cache_if_needed();
|
||||
|
||||
pthread_mutex_lock(&g_adopted_skills_mutex);
|
||||
int cached_count = g_adopted_skills_count;
|
||||
if (cached_count <= 0) {
|
||||
pthread_mutex_unlock(&g_adopted_skills_mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t capacity = (size_t)AGENT_ADOPTED_SKILLS_TOTAL_MAX_CHARS + 2048U;
|
||||
char* payload = (char*)malloc(capacity);
|
||||
if (!payload) {
|
||||
pthread_mutex_unlock(&g_adopted_skills_mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t used = 0;
|
||||
int n = snprintf(payload,
|
||||
capacity,
|
||||
"## Adopted Skills Memory (source: adoption list + startup fallback)\n\nAdopted skills memory (always-on behavioral instructions): follow these skill instructions when relevant and never violate explicit constraints.\n");
|
||||
if (n < 0) {
|
||||
free(payload);
|
||||
pthread_mutex_unlock(&g_adopted_skills_mutex);
|
||||
return -1;
|
||||
}
|
||||
used = (size_t)n < capacity ? (size_t)n : capacity - 1U;
|
||||
|
||||
size_t total_skill_chars = 0;
|
||||
int included = 0;
|
||||
for (int i = 0; i < cached_count && used + 64U < capacity; i++) {
|
||||
const char* skill_content = g_adopted_skills[i].content ? g_adopted_skills[i].content : "";
|
||||
size_t content_len = strlen(skill_content);
|
||||
size_t clipped_len = content_len;
|
||||
|
||||
if (clipped_len > (size_t)AGENT_ADOPTED_SKILL_CONTENT_MAX_CHARS) {
|
||||
clipped_len = (size_t)AGENT_ADOPTED_SKILL_CONTENT_MAX_CHARS;
|
||||
}
|
||||
|
||||
if (total_skill_chars + clipped_len > (size_t)AGENT_ADOPTED_SKILLS_TOTAL_MAX_CHARS) {
|
||||
size_t remaining = (size_t)AGENT_ADOPTED_SKILLS_TOTAL_MAX_CHARS - total_skill_chars;
|
||||
if (remaining == 0) {
|
||||
break;
|
||||
}
|
||||
clipped_len = remaining;
|
||||
}
|
||||
|
||||
int head_n = snprintf(payload + used,
|
||||
capacity - used,
|
||||
"\n\n---\n\n### Skill\nSkill slug: %s\nSkill address: %d:%s:%s\n\nInstructions:\n",
|
||||
g_adopted_skills[i].slug,
|
||||
g_adopted_skills[i].kind,
|
||||
g_adopted_skills[i].author_pubkey_hex,
|
||||
g_adopted_skills[i].slug);
|
||||
if (head_n < 0 || (size_t)head_n >= (capacity - used)) {
|
||||
break;
|
||||
}
|
||||
used += (size_t)head_n;
|
||||
|
||||
size_t write_len = clipped_len;
|
||||
if (write_len >= (capacity - used)) {
|
||||
write_len = capacity - used - 1U;
|
||||
}
|
||||
memcpy(payload + used, skill_content, write_len);
|
||||
used += write_len;
|
||||
payload[used] = '\0';
|
||||
|
||||
int truncated = (content_len > write_len);
|
||||
if (truncated && used + 24U < capacity) {
|
||||
int tail_n = snprintf(payload + used, capacity - used, "\n...[truncated]\n");
|
||||
if (tail_n > 0 && (size_t)tail_n < (capacity - used)) {
|
||||
used += (size_t)tail_n;
|
||||
}
|
||||
} else if (used + 1U < capacity) {
|
||||
payload[used++] = '\n';
|
||||
payload[used] = '\0';
|
||||
}
|
||||
|
||||
total_skill_chars += write_len;
|
||||
included++;
|
||||
if (total_skill_chars >= (size_t)AGENT_ADOPTED_SKILLS_TOTAL_MAX_CHARS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (included < cached_count && used + 96U < capacity) {
|
||||
int extra_n = snprintf(payload + used,
|
||||
capacity - used,
|
||||
"\nAdditional adopted skills omitted due to context budget limits (%d/%d included).\n",
|
||||
included,
|
||||
cached_count);
|
||||
if (extra_n > 0 && (size_t)extra_n < (capacity - used)) {
|
||||
used += (size_t)extra_n;
|
||||
}
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&g_adopted_skills_mutex);
|
||||
|
||||
int rc = append_simple_message(messages, "system", payload);
|
||||
free(payload);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int agent_init(didactyl_config_t* config, const char* system_context) {
|
||||
if (!config || !system_context) {
|
||||
return -1;
|
||||
@@ -485,9 +1008,135 @@ int agent_init(didactyl_config_t* config, const char* system_context) {
|
||||
g_seen_msgs_count = 0;
|
||||
g_seen_msgs_next = 0;
|
||||
|
||||
pthread_mutex_lock(&g_adopted_skills_mutex);
|
||||
clear_adopted_skills_cache_locked();
|
||||
g_adopted_skills_last_refresh_at = 0;
|
||||
pthread_mutex_unlock(&g_adopted_skills_mutex);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
void agent_set_trigger_manager(struct trigger_manager* trigger_manager) {
|
||||
g_trigger_manager = trigger_manager;
|
||||
g_tools_ctx.trigger_manager = trigger_manager;
|
||||
}
|
||||
|
||||
void agent_on_trigger(const char* skill_slug,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url) {
|
||||
if (!g_cfg || !g_system_context || !skill_slug || !skill_content || !triggering_event) {
|
||||
return;
|
||||
}
|
||||
|
||||
char* event_json = cJSON_PrintUnformatted(triggering_event);
|
||||
if (!event_json) {
|
||||
return;
|
||||
}
|
||||
|
||||
const char* relay = (relay_url && relay_url[0] != '\0') ? relay_url : "unknown";
|
||||
|
||||
const char* trigger_prefix =
|
||||
"Triggered-skill execution context:\n"
|
||||
"- You are executing a skill because a Nostr event matched its trigger filter.\n"
|
||||
"- Execute the skill instructions below against the triggering event.\n"
|
||||
"- Keep output concise and actionable for the administrator.\n"
|
||||
"- If no action is needed, explicitly say why.\n\n"
|
||||
"Skill slug: ";
|
||||
|
||||
size_t system_len = strlen(g_system_context) + 2 + strlen(trigger_prefix) + strlen(skill_slug) +
|
||||
strlen("\nRelay: ") + strlen(relay) + strlen("\n\nSkill instructions:\n") +
|
||||
strlen(skill_content) + 1U;
|
||||
char* system_prompt = (char*)malloc(system_len);
|
||||
if (!system_prompt) {
|
||||
free(event_json);
|
||||
return;
|
||||
}
|
||||
|
||||
snprintf(system_prompt,
|
||||
system_len,
|
||||
"%s\n\n%s%s\nRelay: %s\n\nSkill instructions:\n%s",
|
||||
g_system_context,
|
||||
trigger_prefix,
|
||||
skill_slug,
|
||||
relay,
|
||||
skill_content);
|
||||
|
||||
size_t user_len = strlen("Triggering event JSON:\n") + strlen(event_json) + 1U;
|
||||
char* user_prompt = (char*)malloc(user_len);
|
||||
if (!user_prompt) {
|
||||
free(system_prompt);
|
||||
free(event_json);
|
||||
return;
|
||||
}
|
||||
|
||||
snprintf(user_prompt, user_len, "Triggering event JSON:\n%s", event_json);
|
||||
free(event_json);
|
||||
|
||||
append_context_log(g_cfg->admin.pubkey, "llm_trigger", user_prompt);
|
||||
|
||||
char* response = llm_chat(system_prompt, user_prompt);
|
||||
free(system_prompt);
|
||||
free(user_prompt);
|
||||
|
||||
if (!response) {
|
||||
const char* fallback = "Triggered skill execution failed: LLM unavailable.";
|
||||
(void)nostr_handler_send_dm(g_cfg->admin.pubkey, fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
(void)nostr_handler_send_dm(g_cfg->admin.pubkey, response);
|
||||
free(response);
|
||||
}
|
||||
|
||||
int agent_build_admin_messages_json(const char* current_user_message, char** out_messages_json) {
|
||||
if (!out_messages_json || !g_cfg || !g_system_context) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
*out_messages_json = NULL;
|
||||
|
||||
cJSON* messages = cJSON_CreateArray();
|
||||
if (!messages) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (append_simple_message(messages, "system", g_system_context) != 0 ||
|
||||
append_admin_identity_context(messages) != 0 ||
|
||||
append_startup_events_context(messages) != 0 ||
|
||||
append_adopted_skills_context(messages) != 0 ||
|
||||
append_recent_admin_dm_history(messages, current_user_message) != 0) {
|
||||
cJSON_Delete(messages);
|
||||
return -1;
|
||||
}
|
||||
|
||||
char* admin_notes = nostr_handler_get_admin_kind1_notes_context();
|
||||
if (admin_notes) {
|
||||
const char* prefix = "## Administrator Recent Notes (source: nostr kind 1)\n\n";
|
||||
size_t n = strlen(prefix) + strlen(admin_notes) + 1U;
|
||||
char* payload = (char*)malloc(n);
|
||||
if (payload) {
|
||||
snprintf(payload, n, "%s%s", prefix, admin_notes);
|
||||
(void)append_simple_message(messages, "system", payload);
|
||||
free(payload);
|
||||
}
|
||||
free(admin_notes);
|
||||
}
|
||||
|
||||
char* messages_json = cJSON_PrintUnformatted(messages);
|
||||
cJSON_Delete(messages);
|
||||
if (!messages_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
*out_messages_json = messages_json;
|
||||
return 0;
|
||||
}
|
||||
|
||||
tools_context_t* agent_tools_context(void) {
|
||||
return &g_tools_ctx;
|
||||
}
|
||||
|
||||
void agent_on_message(const char* sender_pubkey_hex,
|
||||
const char* message,
|
||||
didactyl_sender_tier_t tier,
|
||||
@@ -555,27 +1204,20 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* messages = cJSON_CreateArray();
|
||||
if (!messages) {
|
||||
free(tools_json);
|
||||
(void)nostr_handler_send_dm(sender_pubkey_hex, "Failed to initialize conversation state.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (append_simple_message(messages, "system", g_system_context) != 0 ||
|
||||
append_admin_identity_context(messages) != 0 ||
|
||||
append_startup_events_context(messages) != 0 ||
|
||||
append_recent_admin_dm_history(messages, message) != 0) {
|
||||
cJSON_Delete(messages);
|
||||
char* base_messages_json = NULL;
|
||||
if (agent_build_admin_messages_json(message, &base_messages_json) != 0 || !base_messages_json) {
|
||||
free(tools_json);
|
||||
(void)nostr_handler_send_dm(sender_pubkey_hex, "Failed to initialize conversation messages.");
|
||||
return;
|
||||
}
|
||||
|
||||
char* admin_notes = nostr_handler_get_admin_kind1_notes_context();
|
||||
if (admin_notes) {
|
||||
(void)append_simple_message(messages, "system", admin_notes);
|
||||
free(admin_notes);
|
||||
cJSON* messages = cJSON_Parse(base_messages_json);
|
||||
free(base_messages_json);
|
||||
if (!messages || !cJSON_IsArray(messages)) {
|
||||
cJSON_Delete(messages);
|
||||
free(tools_json);
|
||||
(void)nostr_handler_send_dm(sender_pubkey_hex, "Failed to initialize conversation state.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (append_simple_message(messages, "user", message) != 0) {
|
||||
@@ -669,7 +1311,13 @@ void agent_cleanup(void) {
|
||||
free(g_system_context);
|
||||
g_system_context = NULL;
|
||||
g_cfg = NULL;
|
||||
g_trigger_manager = NULL;
|
||||
memset(g_seen_msgs, 0, sizeof(g_seen_msgs));
|
||||
g_seen_msgs_count = 0;
|
||||
g_seen_msgs_next = 0;
|
||||
|
||||
pthread_mutex_lock(&g_adopted_skills_mutex);
|
||||
clear_adopted_skills_cache_locked();
|
||||
g_adopted_skills_last_refresh_at = 0;
|
||||
pthread_mutex_unlock(&g_adopted_skills_mutex);
|
||||
}
|
||||
|
||||
+11
@@ -3,12 +3,23 @@
|
||||
|
||||
#include "config.h"
|
||||
#include "nostr_handler.h"
|
||||
#include "cjson/cJSON.h"
|
||||
#include "tools.h"
|
||||
|
||||
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,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url);
|
||||
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);
|
||||
tools_context_t* agent_tools_context(void);
|
||||
void agent_cleanup(void);
|
||||
|
||||
#endif
|
||||
@@ -271,6 +271,84 @@ static int parse_admin_context_config(cJSON* root, didactyl_config_t* config) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_triggers_config(cJSON* root, didactyl_config_t* config) {
|
||||
cJSON* triggers = cJSON_GetObjectItemCaseSensitive(root, "triggers");
|
||||
if (!triggers || !cJSON_IsObject(triggers)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(triggers, "enabled");
|
||||
cJSON* max_active = cJSON_GetObjectItemCaseSensitive(triggers, "max_active");
|
||||
cJSON* cooldown_seconds = cJSON_GetObjectItemCaseSensitive(triggers, "cooldown_seconds");
|
||||
cJSON* llm_rate_limit = cJSON_GetObjectItemCaseSensitive(triggers, "llm_rate_limit_per_minute");
|
||||
cJSON* template_rate_limit = cJSON_GetObjectItemCaseSensitive(triggers, "template_rate_limit_per_minute");
|
||||
|
||||
if (enabled && cJSON_IsBool(enabled)) {
|
||||
config->triggers.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
|
||||
}
|
||||
if (max_active && cJSON_IsNumber(max_active)) {
|
||||
config->triggers.max_active = (int)max_active->valuedouble;
|
||||
}
|
||||
if (cooldown_seconds && cJSON_IsNumber(cooldown_seconds)) {
|
||||
config->triggers.cooldown_seconds = (int)cooldown_seconds->valuedouble;
|
||||
}
|
||||
if (llm_rate_limit && cJSON_IsNumber(llm_rate_limit)) {
|
||||
config->triggers.llm_rate_limit_per_minute = (int)llm_rate_limit->valuedouble;
|
||||
}
|
||||
if (template_rate_limit && cJSON_IsNumber(template_rate_limit)) {
|
||||
config->triggers.template_rate_limit_per_minute = (int)template_rate_limit->valuedouble;
|
||||
}
|
||||
|
||||
if (config->triggers.max_active < 1) {
|
||||
config->triggers.max_active = 1;
|
||||
}
|
||||
if (config->triggers.cooldown_seconds < 0) {
|
||||
config->triggers.cooldown_seconds = 0;
|
||||
}
|
||||
if (config->triggers.llm_rate_limit_per_minute < 1) {
|
||||
config->triggers.llm_rate_limit_per_minute = 1;
|
||||
}
|
||||
if (config->triggers.template_rate_limit_per_minute < 1) {
|
||||
config->triggers.template_rate_limit_per_minute = 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)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(api, "enabled");
|
||||
cJSON* port = cJSON_GetObjectItemCaseSensitive(api, "port");
|
||||
|
||||
if (enabled && cJSON_IsBool(enabled)) {
|
||||
config->api.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
|
||||
}
|
||||
if (port && cJSON_IsNumber(port)) {
|
||||
config->api.port = (int)port->valuedouble;
|
||||
}
|
||||
|
||||
if (copy_json_string(api,
|
||||
"bind_address",
|
||||
config->api.bind_address,
|
||||
sizeof(config->api.bind_address),
|
||||
0) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (config->api.port < 1 || config->api.port > 65535) {
|
||||
config->api.port = 8484;
|
||||
}
|
||||
if (config->api.bind_address[0] == '\0') {
|
||||
snprintf(config->api.bind_address, sizeof(config->api.bind_address), "%s", "127.0.0.1");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static cJSON* find_tag_value_string(cJSON* tags, const char* tag_key) {
|
||||
if (!tags || !cJSON_IsArray(tags) || !tag_key) {
|
||||
return NULL;
|
||||
@@ -558,6 +636,7 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
config_set_error("unknown configuration error");
|
||||
|
||||
memset(config, 0, sizeof(*config));
|
||||
snprintf(config->config_path, sizeof(config->config_path), "%s", path);
|
||||
config->tools.enabled = 1;
|
||||
config->tools.max_turns = 8;
|
||||
config->tools.shell.enabled = 1;
|
||||
@@ -581,6 +660,16 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
config->admin_context.track_kind_1 = 1;
|
||||
config->admin_context.kind_1_limit = 10;
|
||||
|
||||
config->triggers.enabled = 1;
|
||||
config->triggers.max_active = 16;
|
||||
config->triggers.cooldown_seconds = 60;
|
||||
config->triggers.llm_rate_limit_per_minute = 10;
|
||||
config->triggers.template_rate_limit_per_minute = 60;
|
||||
|
||||
config->api.enabled = 0;
|
||||
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) {
|
||||
@@ -680,6 +769,16 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (parse_triggers_config(root, config) != 0) {
|
||||
config_set_error("invalid triggers configuration");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (parse_api_config(root, config) != 0) {
|
||||
config_set_error("invalid api configuration");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (decode_private_key(config->keys.nsec, config->keys.private_key) != 0) {
|
||||
config_set_error("keys.nsec must be valid nsec1... or 64-char hex private key");
|
||||
goto cleanup;
|
||||
|
||||
@@ -72,6 +72,20 @@ typedef struct {
|
||||
int kind_1_limit;
|
||||
} admin_context_config_t;
|
||||
|
||||
typedef struct {
|
||||
int enabled;
|
||||
int max_active;
|
||||
int cooldown_seconds;
|
||||
int llm_rate_limit_per_minute;
|
||||
int template_rate_limit_per_minute;
|
||||
} triggers_config_t;
|
||||
|
||||
typedef struct {
|
||||
int enabled;
|
||||
int port;
|
||||
char bind_address[OW_MAX_URL_LEN];
|
||||
} api_config_t;
|
||||
|
||||
typedef struct {
|
||||
agent_keys_t keys;
|
||||
admin_config_t admin;
|
||||
@@ -81,8 +95,11 @@ typedef struct {
|
||||
tools_config_t tools;
|
||||
security_config_t security;
|
||||
admin_context_config_t admin_context;
|
||||
triggers_config_t triggers;
|
||||
api_config_t api;
|
||||
startup_event_t* startup_events;
|
||||
int startup_event_count;
|
||||
char config_path[OW_MAX_URL_LEN];
|
||||
} didactyl_config_t;
|
||||
|
||||
int config_load(const char* path, didactyl_config_t* config);
|
||||
|
||||
+710
@@ -0,0 +1,710 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "http_api.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "agent.h"
|
||||
#include "llm.h"
|
||||
#include "main.h"
|
||||
#include "nostr_handler.h"
|
||||
#include "trigger_manager.h"
|
||||
#include "tools.h"
|
||||
#include "cjson/cJSON.h"
|
||||
#include "debug.h"
|
||||
#include "mongoose.h"
|
||||
|
||||
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 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";
|
||||
|
||||
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) {
|
||||
mg_http_reply(c, 500, k_json_headers, "{\"success\":false,\"error\":\"json serialization failed\"}");
|
||||
return;
|
||||
}
|
||||
mg_http_reply(c, status, k_json_headers, "%s", out);
|
||||
free(out);
|
||||
}
|
||||
|
||||
static void reply_error(struct mg_connection* c, int status, const char* err) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) {
|
||||
mg_http_reply(c, 500, k_json_headers, "{\"success\":false,\"error\":\"oom\"}");
|
||||
return;
|
||||
}
|
||||
cJSON_AddBoolToObject(root, "success", 0);
|
||||
cJSON_AddStringToObject(root, "error", err ? err : "error");
|
||||
reply_json(c, status, root);
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
|
||||
static cJSON* parse_body_json(const struct mg_http_message* hm) {
|
||||
if (!hm || hm->body.len == 0) {
|
||||
return cJSON_CreateObject();
|
||||
}
|
||||
|
||||
char* buf = (char*)malloc(hm->body.len + 1U);
|
||||
if (!buf) {
|
||||
return NULL;
|
||||
}
|
||||
memcpy(buf, hm->body.buf, hm->body.len);
|
||||
buf[hm->body.len] = '\0';
|
||||
|
||||
cJSON* root = cJSON_Parse(buf);
|
||||
free(buf);
|
||||
return root;
|
||||
}
|
||||
|
||||
static int estimate_tokens_from_chars(int chars) {
|
||||
if (chars <= 0) return 0;
|
||||
return (chars + 3) / 4;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
cJSON* model = cJSON_GetObjectItemCaseSensitive(body, "model");
|
||||
if (!model || !cJSON_IsString(model) || !model->valuestring || model->valuestring[0] == '\0') {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (llm_get_config(out_old_cfg) != 0) {
|
||||
return "failed to read current llm config";
|
||||
}
|
||||
|
||||
llm_config_t next = *out_old_cfg;
|
||||
snprintf(next.model, sizeof(next.model), "%s", model->valuestring);
|
||||
if (llm_set_config(&next) != 0) {
|
||||
return "failed to set temporary model";
|
||||
}
|
||||
|
||||
*out_overridden = 1;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void maybe_model_override_end(const llm_config_t* old_cfg, int overridden) {
|
||||
if (overridden && old_cfg) {
|
||||
(void)llm_set_config(old_cfg);
|
||||
}
|
||||
}
|
||||
|
||||
static const char* classify_part_name(cJSON* msg, int idx) {
|
||||
cJSON* role = cJSON_GetObjectItemCaseSensitive(msg, "role");
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(msg, "content");
|
||||
const char* role_s = (role && cJSON_IsString(role) && role->valuestring) ? role->valuestring : "";
|
||||
const char* content_s = (content && cJSON_IsString(content) && content->valuestring) ? content->valuestring : "";
|
||||
|
||||
if (idx == 0 && strcmp(role_s, "system") == 0) return "system_prompt";
|
||||
if (strncmp(content_s, "## Administrator Identity", 25) == 0 ||
|
||||
strncmp(content_s, "This is your administrator!", 27) == 0) return "admin_identity";
|
||||
if (strncmp(content_s, "## Administrator Kind 0 Profile", 31) == 0 ||
|
||||
strncmp(content_s, "Administrator kind 0 profile content", 36) == 0) return "admin_kind0";
|
||||
if (strncmp(content_s, "## Administrator Relay List", 27) == 0 ||
|
||||
strncmp(content_s, "Administrator kind 10002 relay-list content", 43) == 0) return "admin_relay_list";
|
||||
if (strncmp(content_s, "## Startup Events Memory", 24) == 0 ||
|
||||
strncmp(content_s, "Startup events memory", 21) == 0) return "startup_events";
|
||||
if (strncmp(content_s, "## Adopted Skills Memory", 24) == 0 ||
|
||||
strncmp(content_s, "Adopted skills memory", 21) == 0) return "adopted_skills";
|
||||
if (strncmp(content_s, "## Administrator Recent Notes", 29) == 0 ||
|
||||
strncmp(content_s, "Administrator recent public notes", 33) == 0) return "admin_notes";
|
||||
if (strcmp(role_s, "user") == 0 || strcmp(role_s, "assistant") == 0) return "dm_history";
|
||||
return "context_part";
|
||||
}
|
||||
|
||||
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) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* arr = cJSON_Parse(messages_json);
|
||||
free(messages_json);
|
||||
if (!arr || !cJSON_IsArray(arr)) {
|
||||
cJSON_Delete(arr);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* parts = cJSON_CreateArray();
|
||||
if (!parts) {
|
||||
cJSON_Delete(arr);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int total_chars = 0;
|
||||
int n = cJSON_GetArraySize(arr);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* msg = cJSON_GetArrayItem(arr, i);
|
||||
if (!msg || !cJSON_IsObject(msg)) continue;
|
||||
|
||||
cJSON* role = cJSON_GetObjectItemCaseSensitive(msg, "role");
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(msg, "content");
|
||||
const char* role_s = (role && cJSON_IsString(role) && role->valuestring) ? role->valuestring : "unknown";
|
||||
const char* content_s = (content && cJSON_IsString(content) && content->valuestring) ? content->valuestring : "";
|
||||
|
||||
int chars = (int)strlen(content_s);
|
||||
total_chars += chars;
|
||||
|
||||
cJSON* part = cJSON_CreateObject();
|
||||
if (!part) continue;
|
||||
cJSON_AddStringToObject(part, "name", classify_part_name(msg, i));
|
||||
cJSON_AddStringToObject(part, "role", role_s);
|
||||
cJSON_AddNumberToObject(part, "chars", chars);
|
||||
cJSON_AddNumberToObject(part, "estimated_tokens", estimate_tokens_from_chars(chars));
|
||||
cJSON_AddStringToObject(part, "content", content_s);
|
||||
cJSON_AddItemToArray(parts, part);
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out_root, "success", 1);
|
||||
cJSON_AddNumberToObject(out_root, "total_chars", total_chars);
|
||||
cJSON_AddNumberToObject(out_root, "total_estimated_tokens", estimate_tokens_from_chars(total_chars));
|
||||
cJSON_AddItemToObject(out_root, "parts", parts);
|
||||
|
||||
cJSON_AddItemToObject(out_root, "messages", arr);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int append_simple_message_local(cJSON* messages, const char* role, const char* content) {
|
||||
if (!messages || !role) return -1;
|
||||
cJSON* msg = cJSON_CreateObject();
|
||||
if (!msg) return -1;
|
||||
cJSON_AddStringToObject(msg, "role", role);
|
||||
cJSON_AddStringToObject(msg, "content", content ? content : "");
|
||||
cJSON_AddItemToArray(messages, msg);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int append_assistant_tool_calls_message_local(cJSON* messages, const llm_response_t* resp) {
|
||||
if (!messages || !resp || resp->tool_call_count <= 0) return -1;
|
||||
|
||||
cJSON* assistant = cJSON_CreateObject();
|
||||
cJSON* tool_calls = cJSON_CreateArray();
|
||||
if (!assistant || !tool_calls) {
|
||||
cJSON_Delete(assistant);
|
||||
cJSON_Delete(tool_calls);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(assistant, "role", "assistant");
|
||||
if (resp->content) cJSON_AddStringToObject(assistant, "content", resp->content);
|
||||
else cJSON_AddNullToObject(assistant, "content");
|
||||
|
||||
for (int i = 0; i < resp->tool_call_count; i++) {
|
||||
const llm_tool_call_t* tc = &resp->tool_calls[i];
|
||||
cJSON* tc_obj = cJSON_CreateObject();
|
||||
cJSON* fn_obj = cJSON_CreateObject();
|
||||
if (!tc_obj || !fn_obj) {
|
||||
cJSON_Delete(tc_obj);
|
||||
cJSON_Delete(fn_obj);
|
||||
cJSON_Delete(assistant);
|
||||
cJSON_Delete(tool_calls);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(tc_obj, "id", tc->id ? tc->id : "");
|
||||
cJSON_AddStringToObject(tc_obj, "type", "function");
|
||||
cJSON_AddStringToObject(fn_obj, "name", tc->name ? tc->name : "");
|
||||
cJSON_AddStringToObject(fn_obj, "arguments", tc->arguments_json ? tc->arguments_json : "{}");
|
||||
cJSON_AddItemToObject(tc_obj, "function", fn_obj);
|
||||
cJSON_AddItemToArray(tool_calls, tc_obj);
|
||||
}
|
||||
|
||||
cJSON_AddItemToObject(assistant, "tool_calls", tool_calls);
|
||||
cJSON_AddItemToArray(messages, assistant);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int append_tool_result_message_local(cJSON* messages, const char* tool_call_id, const char* tool_result_json) {
|
||||
if (!messages || !tool_call_id) return -1;
|
||||
|
||||
cJSON* msg = cJSON_CreateObject();
|
||||
if (!msg) return -1;
|
||||
|
||||
cJSON_AddStringToObject(msg, "role", "tool");
|
||||
cJSON_AddStringToObject(msg, "tool_call_id", tool_call_id);
|
||||
cJSON_AddStringToObject(msg,
|
||||
"content",
|
||||
tool_result_json ? tool_result_json : "{\"success\":false,\"error\":\"empty tool result\"}");
|
||||
cJSON_AddItemToArray(messages, msg);
|
||||
return 0;
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON* turns = cJSON_CreateArray();
|
||||
if (!root || !turns) {
|
||||
cJSON_Delete(root);
|
||||
cJSON_Delete(turns);
|
||||
free(tools_json);
|
||||
cJSON_Delete(convo);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char* final_response = NULL;
|
||||
|
||||
for (int turn = 0; turn < max_turns; turn++) {
|
||||
char* messages_json = cJSON_PrintUnformatted(convo);
|
||||
if (!messages_json) break;
|
||||
|
||||
llm_response_t resp;
|
||||
int rc = llm_chat_with_tools_messages(messages_json, tools_json, "auto", &resp);
|
||||
free(messages_json);
|
||||
|
||||
if (rc != 0) {
|
||||
final_response = strdup("LLM request failed.");
|
||||
break;
|
||||
}
|
||||
|
||||
cJSON* turn_obj = cJSON_CreateObject();
|
||||
if (turn_obj) {
|
||||
cJSON_AddNumberToObject(turn_obj, "turn", turn + 1);
|
||||
cJSON* tool_calls = cJSON_CreateArray();
|
||||
if (tool_calls) cJSON_AddItemToObject(turn_obj, "tool_calls", tool_calls);
|
||||
cJSON_AddItemToArray(turns, turn_obj);
|
||||
}
|
||||
|
||||
if (resp.tool_call_count <= 0) {
|
||||
final_response = strdup(resp.content ? resp.content : "No response content.");
|
||||
llm_response_free(&resp);
|
||||
break;
|
||||
}
|
||||
|
||||
if (append_assistant_tool_calls_message_local(convo, &resp) != 0) {
|
||||
llm_response_free(&resp);
|
||||
final_response = strdup("Failed to append assistant tool calls.");
|
||||
break;
|
||||
}
|
||||
|
||||
cJSON* current_turn = cJSON_GetArrayItem(turns, cJSON_GetArraySize(turns) - 1);
|
||||
cJSON* turn_tool_calls = current_turn ? cJSON_GetObjectItemCaseSensitive(current_turn, "tool_calls") : NULL;
|
||||
|
||||
for (int i = 0; i < resp.tool_call_count; i++) {
|
||||
llm_tool_call_t* tc = &resp.tool_calls[i];
|
||||
char* tool_result = tools_execute(g_api_ctx.tools_ctx, tc->name, tc->arguments_json);
|
||||
if (!tool_result) {
|
||||
tool_result = strdup("{\"success\":false,\"error\":\"tool execution failed\"}");
|
||||
}
|
||||
|
||||
if (turn_tool_calls && cJSON_IsArray(turn_tool_calls)) {
|
||||
cJSON* tr = cJSON_CreateObject();
|
||||
if (tr) {
|
||||
cJSON_AddStringToObject(tr, "name", tc->name ? tc->name : "");
|
||||
cJSON_AddStringToObject(tr, "arguments", tc->arguments_json ? tc->arguments_json : "{}");
|
||||
cJSON_AddStringToObject(tr, "result", tool_result ? tool_result : "{}");
|
||||
cJSON_AddItemToArray(turn_tool_calls, tr);
|
||||
}
|
||||
}
|
||||
|
||||
if (append_tool_result_message_local(convo,
|
||||
tc->id ? tc->id : "",
|
||||
tool_result ? tool_result : "{\"success\":false,\"error\":\"tool execution failed\"}") != 0) {
|
||||
free(tool_result);
|
||||
llm_response_free(&resp);
|
||||
final_response = strdup("Failed to append tool result.");
|
||||
goto done;
|
||||
}
|
||||
free(tool_result);
|
||||
}
|
||||
|
||||
llm_response_free(&resp);
|
||||
}
|
||||
|
||||
done:
|
||||
if (!final_response) {
|
||||
final_response = strdup("I hit my tool-use limit for this prompt run.");
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddStringToObject(root, "final_response", final_response ? final_response : "");
|
||||
cJSON_AddItemToObject(root, "turns", turns);
|
||||
|
||||
llm_config_t cfg;
|
||||
if (llm_get_config(&cfg) == 0) {
|
||||
cJSON_AddStringToObject(root, "model_used", cfg.model);
|
||||
}
|
||||
|
||||
int input_chars = 0;
|
||||
char* convo_json = cJSON_PrintUnformatted(convo);
|
||||
if (convo_json) {
|
||||
input_chars = (int)strlen(convo_json);
|
||||
free(convo_json);
|
||||
}
|
||||
cJSON_AddNumberToObject(root, "total_input_tokens_estimate", estimate_tokens_from_chars(input_chars));
|
||||
cJSON_AddNumberToObject(root,
|
||||
"total_output_tokens_estimate",
|
||||
estimate_tokens_from_chars((int)strlen(final_response ? final_response : "")));
|
||||
|
||||
free(final_response);
|
||||
free(tools_json);
|
||||
cJSON_Delete(convo);
|
||||
return root;
|
||||
}
|
||||
|
||||
static void handle_status(struct mg_connection* c) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) {
|
||||
reply_error(c, 500, "oom");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddStringToObject(root, "name", DIDACTYL_NAME);
|
||||
cJSON_AddStringToObject(root, "version", DIDACTYL_VERSION);
|
||||
|
||||
if (g_api_ctx.cfg) {
|
||||
cJSON_AddStringToObject(root, "pubkey", g_api_ctx.cfg->keys.public_key_hex);
|
||||
cJSON_AddNumberToObject(root, "relay_count", g_api_ctx.cfg->relay_count);
|
||||
}
|
||||
|
||||
cJSON_AddNumberToObject(root, "connected_relays", nostr_handler_connected_relay_count());
|
||||
if (g_api_ctx.trigger_manager) {
|
||||
cJSON_AddNumberToObject(root, "active_triggers", trigger_manager_active_count(g_api_ctx.trigger_manager));
|
||||
}
|
||||
|
||||
reply_json(c, 200, root);
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
|
||||
static void handle_context_current(struct mg_connection* c) {
|
||||
char* messages_json = NULL;
|
||||
if (agent_build_admin_messages_json("", &messages_json) != 0 || !messages_json) {
|
||||
reply_error(c, 500, "failed to build context messages");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* messages = cJSON_Parse(messages_json);
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root || !messages || !cJSON_IsArray(messages)) {
|
||||
free(messages_json);
|
||||
cJSON_Delete(messages);
|
||||
cJSON_Delete(root);
|
||||
reply_error(c, 500, "failed to serialize context");
|
||||
return;
|
||||
}
|
||||
|
||||
int chars = (int)strlen(messages_json);
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddNumberToObject(root, "total_chars", chars);
|
||||
cJSON_AddNumberToObject(root, "total_estimated_tokens", estimate_tokens_from_chars(chars));
|
||||
cJSON_AddItemToObject(root, "messages", messages);
|
||||
|
||||
reply_json(c, 200, root);
|
||||
|
||||
free(messages_json);
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
|
||||
static void handle_context_parts(struct mg_connection* c) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) {
|
||||
reply_error(c, 500, "oom");
|
||||
return;
|
||||
}
|
||||
|
||||
if (build_context_parts_response(root) != 0) {
|
||||
cJSON_Delete(root);
|
||||
reply_error(c, 500, "failed to build context parts");
|
||||
return;
|
||||
}
|
||||
|
||||
reply_json(c, 200, root);
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
|
||||
static void handle_prompt_run_simple(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* system = cJSON_GetObjectItemCaseSensitive(body, "system");
|
||||
cJSON* user = cJSON_GetObjectItemCaseSensitive(body, "user");
|
||||
if (!system || !user || !cJSON_IsString(system) || !cJSON_IsString(user) ||
|
||||
!system->valuestring || !user->valuestring) {
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 400, "system and user strings are required");
|
||||
return;
|
||||
}
|
||||
|
||||
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* response = llm_chat(system->valuestring, user->valuestring);
|
||||
maybe_model_override_end(&old_cfg, overridden);
|
||||
|
||||
if (!response) {
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 500, "llm request failed");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) {
|
||||
free(response);
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 500, "oom");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddStringToObject(root, "response", response);
|
||||
|
||||
llm_config_t cfg;
|
||||
if (llm_get_config(&cfg) == 0) {
|
||||
cJSON_AddStringToObject(root, "model_used", cfg.model);
|
||||
}
|
||||
|
||||
int in_chars = (int)strlen(system->valuestring) + (int)strlen(user->valuestring);
|
||||
cJSON_AddNumberToObject(root, "input_tokens_estimate", estimate_tokens_from_chars(in_chars));
|
||||
cJSON_AddNumberToObject(root, "output_tokens_estimate", estimate_tokens_from_chars((int)strlen(response)));
|
||||
|
||||
free(response);
|
||||
cJSON_Delete(body);
|
||||
reply_json(c, 200, root);
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
|
||||
static void handle_prompt_run(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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
cJSON* result = run_prompt_with_tools(body);
|
||||
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);
|
||||
if (!body_copy) return NULL;
|
||||
|
||||
llm_config_t old_cfg;
|
||||
int overridden = 0;
|
||||
char* model_err = maybe_model_override_begin(body_copy, &old_cfg, &overridden);
|
||||
if (model_err) {
|
||||
cJSON* err = cJSON_CreateObject();
|
||||
if (err) {
|
||||
cJSON_AddBoolToObject(err, "success", 0);
|
||||
cJSON_AddStringToObject(err, "error", model_err);
|
||||
}
|
||||
cJSON_Delete(body_copy);
|
||||
return err;
|
||||
}
|
||||
|
||||
cJSON* out = run_prompt_with_tools(body_copy);
|
||||
maybe_model_override_end(&old_cfg, overridden);
|
||||
cJSON_Delete(body_copy);
|
||||
return out;
|
||||
}
|
||||
|
||||
static void handle_prompt_compare(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* a = cJSON_GetObjectItemCaseSensitive(body, "variant_a");
|
||||
cJSON* b = cJSON_GetObjectItemCaseSensitive(body, "variant_b");
|
||||
if (!a || !b || !cJSON_IsObject(a) || !cJSON_IsObject(b)) {
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 400, "variant_a and variant_b objects are required");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* out_a = run_variant(a);
|
||||
cJSON* out_b = run_variant(b);
|
||||
|
||||
if (!out_a || !out_b) {
|
||||
cJSON_Delete(out_a);
|
||||
cJSON_Delete(out_b);
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 500, "failed to run one or more variants");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) {
|
||||
cJSON_Delete(out_a);
|
||||
cJSON_Delete(out_b);
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 500, "oom");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddItemToObject(root, "variant_a", out_a);
|
||||
cJSON_AddItemToObject(root, "variant_b", out_b);
|
||||
|
||||
cJSON_Delete(body);
|
||||
reply_json(c, 200, root);
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
|
||||
static void http_handler(struct mg_connection* c, int ev, void* ev_data) {
|
||||
if (ev != MG_EV_HTTP_MSG) {
|
||||
return;
|
||||
}
|
||||
|
||||
struct mg_http_message* hm = (struct mg_http_message*)ev_data;
|
||||
|
||||
if (method_is(hm, "OPTIONS")) {
|
||||
mg_http_reply(c, 204, k_json_headers, "");
|
||||
return;
|
||||
}
|
||||
|
||||
if (method_is(hm, "GET") && mg_match(hm->uri, mg_str("/api/status"), NULL)) {
|
||||
handle_status(c);
|
||||
return;
|
||||
}
|
||||
if (method_is(hm, "GET") && mg_match(hm->uri, mg_str("/api/context/current"), NULL)) {
|
||||
handle_context_current(c);
|
||||
return;
|
||||
}
|
||||
if (method_is(hm, "GET") && mg_match(hm->uri, mg_str("/api/context/parts"), NULL)) {
|
||||
handle_context_parts(c);
|
||||
return;
|
||||
}
|
||||
if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/prompt/run-simple"), NULL)) {
|
||||
handle_prompt_run_simple(c, hm);
|
||||
return;
|
||||
}
|
||||
if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/prompt/run"), NULL)) {
|
||||
handle_prompt_run(c, hm);
|
||||
return;
|
||||
}
|
||||
if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/prompt/compare"), NULL)) {
|
||||
handle_prompt_compare(c, hm);
|
||||
return;
|
||||
}
|
||||
|
||||
reply_error(c, 404, "not found");
|
||||
}
|
||||
|
||||
int http_api_init(const http_api_context_t* ctx) {
|
||||
if (!ctx || !ctx->cfg || !ctx->tools_ctx) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_api_ctx = *ctx;
|
||||
|
||||
mg_mgr_init(&g_mgr);
|
||||
|
||||
char listen_url[320];
|
||||
snprintf(listen_url,
|
||||
sizeof(listen_url),
|
||||
"http://%s:%d",
|
||||
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);
|
||||
|
||||
g_listener = mg_http_listen(&g_mgr, listen_url, http_handler, NULL);
|
||||
if (!g_listener) {
|
||||
mg_mgr_free(&g_mgr);
|
||||
memset(&g_mgr, 0, sizeof(g_mgr));
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_api_initialized = 1;
|
||||
DEBUG_INFO("[didactyl] http api listening on %s", listen_url);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int http_api_poll(int timeout_ms) {
|
||||
if (!g_api_initialized) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (timeout_ms < 0) timeout_ms = 0;
|
||||
mg_mgr_poll(&g_mgr, timeout_ms);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void http_api_cleanup(void) {
|
||||
if (!g_api_initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
mg_mgr_free(&g_mgr);
|
||||
memset(&g_mgr, 0, sizeof(g_mgr));
|
||||
g_listener = NULL;
|
||||
memset(&g_api_ctx, 0, sizeof(g_api_ctx));
|
||||
g_api_initialized = 0;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#ifndef DIDACTYL_HTTP_API_H
|
||||
#define DIDACTYL_HTTP_API_H
|
||||
|
||||
#include "config.h"
|
||||
#include "tools.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
typedef struct {
|
||||
didactyl_config_t* cfg;
|
||||
tools_context_t* tools_ctx;
|
||||
struct trigger_manager* trigger_manager;
|
||||
} http_api_context_t;
|
||||
|
||||
int http_api_init(const http_api_context_t* ctx);
|
||||
int http_api_poll(int timeout_ms);
|
||||
void http_api_cleanup(void);
|
||||
|
||||
#endif
|
||||
@@ -64,15 +64,13 @@ static const char* detect_ca_bundle_path(void) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static char* perform_chat_request(const char* body) {
|
||||
static char* perform_http_request(const char* url, const char* body, int is_post) {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl || !body) {
|
||||
if (!curl || !url) {
|
||||
if (curl) curl_easy_cleanup(curl);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char url[OW_MAX_URL_LEN + 64];
|
||||
snprintf(url, sizeof(url), "%s/chat/completions", g_cfg.base_url);
|
||||
|
||||
response_buffer_t rb = {0};
|
||||
struct curl_slist* headers = NULL;
|
||||
@@ -83,8 +81,11 @@ static char* perform_chat_request(const char* body) {
|
||||
headers = curl_slist_append(headers, auth_header);
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPGET, is_post ? 0L : 1L);
|
||||
if (is_post) {
|
||||
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body ? body : "{}");
|
||||
}
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb);
|
||||
@@ -132,6 +133,12 @@ static char* perform_chat_request(const char* body) {
|
||||
return rb.data;
|
||||
}
|
||||
|
||||
static char* perform_chat_request(const char* body) {
|
||||
char url[OW_MAX_URL_LEN + 64];
|
||||
snprintf(url, sizeof(url), "%s/chat/completions", g_cfg.base_url);
|
||||
return perform_http_request(url, body, 1);
|
||||
}
|
||||
|
||||
static char* build_request_json(const char* system_prompt, const char* user_message) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON* messages = cJSON_CreateArray();
|
||||
@@ -388,6 +395,36 @@ void llm_response_free(llm_response_t* response) {
|
||||
memset(response, 0, sizeof(*response));
|
||||
}
|
||||
|
||||
int llm_get_config(llm_config_t* out_config) {
|
||||
if (!g_initialized || !out_config) {
|
||||
return -1;
|
||||
}
|
||||
*out_config = g_cfg;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int llm_set_config(const llm_config_t* config) {
|
||||
if (!g_initialized || !config) {
|
||||
return -1;
|
||||
}
|
||||
g_cfg = *config;
|
||||
return 0;
|
||||
}
|
||||
|
||||
char* llm_list_models_json(const char* base_url_override) {
|
||||
if (!g_initialized) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const char* base_url = (base_url_override && base_url_override[0] != '\0')
|
||||
? base_url_override
|
||||
: g_cfg.base_url;
|
||||
|
||||
char url[OW_MAX_URL_LEN + 32];
|
||||
snprintf(url, sizeof(url), "%s/models", base_url);
|
||||
return perform_http_request(url, NULL, 0);
|
||||
}
|
||||
|
||||
void llm_cleanup(void) {
|
||||
if (!g_initialized) {
|
||||
return;
|
||||
|
||||
@@ -26,6 +26,9 @@ int llm_chat_with_tools_messages(const char* messages_json,
|
||||
const char* tool_choice,
|
||||
llm_response_t* out_response);
|
||||
void llm_response_free(llm_response_t* response);
|
||||
int llm_get_config(llm_config_t* out_config);
|
||||
int llm_set_config(const llm_config_t* config);
|
||||
char* llm_list_models_json(const char* base_url_override);
|
||||
void llm_cleanup(void);
|
||||
|
||||
#endif
|
||||
+175
-1
@@ -4,6 +4,7 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
#include "main.h"
|
||||
@@ -12,6 +13,10 @@
|
||||
#include "llm.h"
|
||||
#include "nostr_handler.h"
|
||||
#include "debug.h"
|
||||
#include "trigger_manager.h"
|
||||
#include "tools.h"
|
||||
#include "cjson/cJSON.h"
|
||||
#include "http_api.h"
|
||||
|
||||
static volatile sig_atomic_t g_running = 1;
|
||||
|
||||
@@ -22,13 +27,53 @@ static void signal_handler(int signum) {
|
||||
|
||||
static void print_usage(const char* prog) {
|
||||
fprintf(stderr,
|
||||
"Usage: %s [-h|--help] [-v|--version] [--config <path>] [--debug <0-5>]\n",
|
||||
"Usage: %s [-h|--help] [-v|--version] [--config <path>] [--debug <0-5>] [--dump-schemas] [--test-tool <name> <args_json>]\n",
|
||||
prog ? prog : "didactyl");
|
||||
}
|
||||
|
||||
static int tool_result_success(const char* json) {
|
||||
if (!json) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_Parse(json);
|
||||
if (!root || !cJSON_IsObject(root)) {
|
||||
cJSON_Delete(root);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* success = cJSON_GetObjectItemCaseSensitive(root, "success");
|
||||
int ok = (success && cJSON_IsBool(success) && cJSON_IsTrue(success)) ? 1 : 0;
|
||||
cJSON_Delete(root);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static int wait_for_connected_relays(int timeout_ms) {
|
||||
if (timeout_ms <= 0) {
|
||||
return nostr_handler_connected_relay_count() > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
const int step_ms = 100;
|
||||
int elapsed_ms = 0;
|
||||
|
||||
while (elapsed_ms < timeout_ms) {
|
||||
if (nostr_handler_connected_relay_count() > 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
(void)nostr_handler_poll(step_ms);
|
||||
elapsed_ms += step_ms;
|
||||
}
|
||||
|
||||
return nostr_handler_connected_relay_count() > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const char* config_path = "./config.json";
|
||||
int debug_level = DEBUG_LEVEL_TRACE;
|
||||
int dump_schemas = 0;
|
||||
const char* test_tool_name = NULL;
|
||||
const char* test_tool_args = "{}";
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
|
||||
@@ -41,6 +86,11 @@ int main(int argc, char** argv) {
|
||||
config_path = argv[++i];
|
||||
} else if (strcmp(argv[i], "--debug") == 0 && i + 1 < argc) {
|
||||
debug_level = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--dump-schemas") == 0) {
|
||||
dump_schemas = 1;
|
||||
} else if (strcmp(argv[i], "--test-tool") == 0 && i + 2 < argc) {
|
||||
test_tool_name = argv[++i];
|
||||
test_tool_args = argv[++i];
|
||||
} else {
|
||||
print_usage(argv[0]);
|
||||
return 1;
|
||||
@@ -64,6 +114,79 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (dump_schemas || test_tool_name) {
|
||||
if (llm_init(&cfg.llm) != 0) {
|
||||
fprintf(stderr, "Failed to initialize llm client\n");
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (nostr_handler_init(&cfg) != 0) {
|
||||
fprintf(stderr, "Failed to initialize nostr handler\n");
|
||||
config_free(&cfg);
|
||||
llm_cleanup();
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
tools_context_t tools_ctx;
|
||||
if (tools_init(&tools_ctx, &cfg) != 0) {
|
||||
fprintf(stderr, "Failed to initialize tools context\n");
|
||||
nostr_handler_cleanup();
|
||||
config_free(&cfg);
|
||||
llm_cleanup();
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
int exit_code = 0;
|
||||
|
||||
if (test_tool_name) {
|
||||
const int timeout_ms = 15000;
|
||||
if (!wait_for_connected_relays(timeout_ms)) {
|
||||
fprintf(stderr,
|
||||
"No relay connection established within %d ms; refusing to run --test-tool '%s'\n",
|
||||
timeout_ms,
|
||||
test_tool_name);
|
||||
tools_cleanup(&tools_ctx);
|
||||
nostr_handler_cleanup();
|
||||
config_free(&cfg);
|
||||
llm_cleanup();
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (dump_schemas) {
|
||||
char* schemas = tools_build_openai_schema_json(&tools_ctx);
|
||||
if (!schemas) {
|
||||
fprintf(stderr, "Failed to build tool schemas\n");
|
||||
exit_code = 1;
|
||||
} else {
|
||||
printf("%s\n", schemas);
|
||||
free(schemas);
|
||||
}
|
||||
} else {
|
||||
char* result = tools_execute(&tools_ctx, test_tool_name, test_tool_args);
|
||||
if (!result) {
|
||||
fprintf(stderr, "Failed to execute tool\n");
|
||||
exit_code = 1;
|
||||
} else {
|
||||
printf("%s\n", result);
|
||||
exit_code = tool_result_success(result) ? 0 : 1;
|
||||
free(result);
|
||||
}
|
||||
}
|
||||
|
||||
tools_cleanup(&tools_ctx);
|
||||
nostr_handler_cleanup();
|
||||
config_free(&cfg);
|
||||
llm_cleanup();
|
||||
nostr_cleanup();
|
||||
return exit_code;
|
||||
}
|
||||
|
||||
if (llm_init(&cfg.llm) != 0) {
|
||||
fprintf(stderr, "Failed to initialize llm client\n");
|
||||
config_free(&cfg);
|
||||
@@ -97,6 +220,22 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
trigger_manager_t trigger_manager;
|
||||
if (trigger_manager_init(&trigger_manager, &cfg) != 0) {
|
||||
fprintf(stderr, "Failed to initialize trigger manager\n");
|
||||
agent_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
agent_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)");
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] startup phase: subscribe admin context begin");
|
||||
if (nostr_handler_subscribe_admin_context() != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: subscribe admin context failed (continuing)");
|
||||
@@ -124,6 +263,7 @@ int main(int argc, char** argv) {
|
||||
agent_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
trigger_manager_cleanup(&trigger_manager);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
@@ -133,21 +273,55 @@ int main(int argc, char** argv) {
|
||||
|
||||
signal(SIGINT, signal_handler);
|
||||
signal(SIGTERM, signal_handler);
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
|
||||
int http_api_started = 0;
|
||||
if (cfg.api.enabled) {
|
||||
http_api_context_t http_ctx;
|
||||
memset(&http_ctx, 0, sizeof(http_ctx));
|
||||
http_ctx.cfg = &cfg;
|
||||
http_ctx.tools_ctx = agent_tools_context();
|
||||
http_ctx.trigger_manager = &trigger_manager;
|
||||
|
||||
if (http_api_init(&http_ctx) != 0) {
|
||||
fprintf(stderr,
|
||||
"Failed to initialize HTTP API on %s:%d\n",
|
||||
cfg.api.bind_address,
|
||||
cfg.api.port);
|
||||
agent_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
trigger_manager_cleanup(&trigger_manager);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
http_api_started = 1;
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] entering main poll loop");
|
||||
DEBUG_INFO("[didactyl] running with pubkey %s", cfg.keys.public_key_hex);
|
||||
|
||||
while (g_running) {
|
||||
(void)nostr_handler_poll(100);
|
||||
(void)trigger_manager_poll(&trigger_manager);
|
||||
if (http_api_started) {
|
||||
(void)http_api_poll(0);
|
||||
}
|
||||
struct timespec ts = {0, 10 * 1000 * 1000};
|
||||
nanosleep(&ts, NULL);
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] shutting down");
|
||||
|
||||
if (http_api_started) {
|
||||
http_api_cleanup();
|
||||
}
|
||||
|
||||
agent_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
trigger_manager_cleanup(&trigger_manager);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
|
||||
|
||||
+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 21
|
||||
#define DIDACTYL_VERSION "v0.0.21"
|
||||
#define DIDACTYL_VERSION_PATCH 27
|
||||
#define DIDACTYL_VERSION "v0.0.27"
|
||||
|
||||
// Agent metadata
|
||||
#define DIDACTYL_NAME "Didactyl"
|
||||
|
||||
+28193
File diff suppressed because it is too large
Load Diff
+4038
File diff suppressed because it is too large
Load Diff
+32
-1
@@ -429,7 +429,13 @@ static void trace_plaintext_dm(const char* prefix, const char* plaintext) {
|
||||
static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
(void)user_data;
|
||||
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event ENTRY from %s (event=%p g_cfg=%p g_dm_callback=%s)",
|
||||
relay_url ? relay_url : "NULL", (void*)event, (void*)g_cfg,
|
||||
g_dm_callback ? "set" : "NULL");
|
||||
|
||||
if (!event || !g_cfg || !g_dm_callback) {
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event NULL guard: event=%p g_cfg=%p g_dm_callback=%s",
|
||||
(void*)event, (void*)g_cfg, g_dm_callback ? "set" : "NULL");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -447,6 +453,8 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
|
||||
if (!kind || !pubkey || !content || !tags ||
|
||||
!cJSON_IsNumber(kind) || !cJSON_IsString(pubkey) || !cJSON_IsString(content)) {
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: missing required fields (kind=%p pubkey=%p content=%p tags=%p)",
|
||||
(void*)kind, (void*)pubkey, (void*)content, (void*)tags);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -454,16 +462,27 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
? id->valuestring
|
||||
: NULL;
|
||||
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: kind=%d id=%.16s... from=%.16s... via %s",
|
||||
(int)kind->valuedouble,
|
||||
event_id_hex ? event_id_hex : "<no-id>",
|
||||
pubkey->valuestring ? pubkey->valuestring : "<no-pk>",
|
||||
relay_url ? relay_url : "unknown");
|
||||
|
||||
if ((int)kind->valuedouble != 4) {
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: ignoring non-kind4 (kind=%d)", (int)kind->valuedouble);
|
||||
return;
|
||||
}
|
||||
|
||||
char recipient_pubkey_hex[65] = {0};
|
||||
if (extract_first_p_tag(tags, recipient_pubkey_hex) != 0) {
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: no p-tag found in kind4 event %.16s...",
|
||||
event_id_hex ? event_id_hex : "<no-id>");
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(recipient_pubkey_hex, g_cfg->keys.public_key_hex) != 0) {
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: p-tag mismatch (got=%.16s... want=%.16s...)",
|
||||
recipient_pubkey_hex, g_cfg->keys.public_key_hex);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -474,6 +493,9 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
tier = DIDACTYL_SENDER_WOT;
|
||||
}
|
||||
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: sender=%.16s... tier=%d (admin=%.16s...)",
|
||||
pubkey->valuestring, (int)tier, g_cfg->admin.pubkey);
|
||||
|
||||
if (tier == DIDACTYL_SENDER_STRANGER) {
|
||||
if (!g_cfg->security.stranger.enabled) {
|
||||
DEBUG_LOG("[didactyl] ignored DM from stranger %.16s... via %s",
|
||||
@@ -531,8 +553,8 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
|
||||
static void on_eose(cJSON** events, int event_count, void* user_data) {
|
||||
(void)events;
|
||||
(void)event_count;
|
||||
(void)user_data;
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_eose called: event_count=%d", event_count);
|
||||
}
|
||||
|
||||
static void free_admin_context_locked(void) {
|
||||
@@ -867,6 +889,14 @@ int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) {
|
||||
cJSON_AddNumberToObject(filter, "since", (double)g_start_time);
|
||||
cJSON_AddNumberToObject(filter, "limit", 100);
|
||||
|
||||
{
|
||||
char* filter_str = cJSON_PrintUnformatted(filter);
|
||||
DEBUG_TRACE("[didactyl] DEBUG DM subscription filter: %s", filter_str ? filter_str : "<null>");
|
||||
DEBUG_TRACE("[didactyl] DEBUG DM subscription g_start_time=%ld now=%ld delta=%ld relay_count=%d",
|
||||
(long)g_start_time, (long)time(NULL), (long)(time(NULL) - g_start_time), g_cfg->relay_count);
|
||||
free(filter_str);
|
||||
}
|
||||
|
||||
nostr_pool_subscription_t* sub = nostr_relay_pool_subscribe(
|
||||
g_pool,
|
||||
(const char**)g_cfg->relays,
|
||||
@@ -888,6 +918,7 @@ int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) {
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] DM subscription active for pubkey %.16s...", g_cfg->keys.public_key_hex);
|
||||
DEBUG_TRACE("[didactyl] DEBUG DM subscription sub=%p close_on_eose=0 dedup=1", (void*)sub);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
+983
-12
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,11 @@
|
||||
|
||||
#include "config.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
typedef struct {
|
||||
didactyl_config_t* cfg;
|
||||
struct trigger_manager* trigger_manager;
|
||||
} tools_context_t;
|
||||
|
||||
int tools_init(tools_context_t* ctx, didactyl_config_t* cfg);
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "trigger_manager.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "agent.h"
|
||||
#include "cjson/cJSON.h"
|
||||
#include "debug.h"
|
||||
#include "nostr_handler.h"
|
||||
|
||||
static int clamp_enabled(int enabled) {
|
||||
return enabled ? 1 : 0;
|
||||
}
|
||||
|
||||
static int ensure_capacity(trigger_manager_t* mgr, int needed) {
|
||||
if (!mgr || needed <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (mgr->capacity >= needed) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int new_cap = mgr->capacity > 0 ? mgr->capacity : TRIGGER_DEFAULT_MAX_ACTIVE;
|
||||
while (new_cap < needed) {
|
||||
if (new_cap > 1024) {
|
||||
return -1;
|
||||
}
|
||||
new_cap *= 2;
|
||||
}
|
||||
|
||||
active_trigger_t* grown = (active_trigger_t*)realloc(mgr->triggers, (size_t)new_cap * sizeof(active_trigger_t));
|
||||
if (!grown) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (new_cap > mgr->capacity) {
|
||||
memset(&grown[mgr->capacity], 0, (size_t)(new_cap - mgr->capacity) * sizeof(active_trigger_t));
|
||||
}
|
||||
|
||||
mgr->triggers = grown;
|
||||
mgr->capacity = new_cap;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int find_trigger_index_locked(trigger_manager_t* mgr, const char* skill_slug) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
if (strncmp(mgr->triggers[i].skill_slug, skill_slug, TRIGGER_SKILL_SLUG_MAX) == 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
static cJSON* find_tag_value_string(cJSON* tags, const char* key) {
|
||||
if (!tags || !key || !cJSON_IsArray(tags)) return NULL;
|
||||
|
||||
int n = cJSON_GetArraySize(tags);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(tags, i);
|
||||
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue;
|
||||
|
||||
cJSON* k = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* v = cJSON_GetArrayItem(tag, 1);
|
||||
if (k && v && cJSON_IsString(k) && cJSON_IsString(v) &&
|
||||
k->valuestring && v->valuestring && strcmp(k->valuestring, key) == 0) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int parse_address_tag(const char* addr, int* out_kind, char out_pubkey[65], char out_slug[65]) {
|
||||
if (!addr || !out_kind || !out_pubkey || !out_slug) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char* p1 = strchr(addr, ':');
|
||||
if (!p1) return -1;
|
||||
const char* p2 = strchr(p1 + 1, ':');
|
||||
if (!p2) return -1;
|
||||
|
||||
char kind_buf[16] = {0};
|
||||
size_t kind_len = (size_t)(p1 - addr);
|
||||
if (kind_len == 0 || kind_len >= sizeof(kind_buf)) return -1;
|
||||
memcpy(kind_buf, addr, kind_len);
|
||||
|
||||
int kind = atoi(kind_buf);
|
||||
if (kind != 31123 && kind != 31124) return -1;
|
||||
|
||||
size_t pub_len = (size_t)(p2 - (p1 + 1));
|
||||
if (pub_len != 64U) return -1;
|
||||
memcpy(out_pubkey, p1 + 1, 64U);
|
||||
out_pubkey[64] = '\0';
|
||||
|
||||
size_t slug_len = strlen(p2 + 1);
|
||||
if (slug_len == 0 || slug_len >= 65U) return -1;
|
||||
memcpy(out_slug, p2 + 1, slug_len + 1U);
|
||||
|
||||
*out_kind = kind;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static char* build_template_output(const active_trigger_t* t, cJSON* event, const char* relay_url) {
|
||||
(void)relay_url;
|
||||
|
||||
if (!t || !event) return NULL;
|
||||
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
|
||||
cJSON* id = cJSON_GetObjectItemCaseSensitive(event, "id");
|
||||
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
|
||||
|
||||
const char* content_s = (content && cJSON_IsString(content) && content->valuestring) ? content->valuestring : "";
|
||||
const char* pubkey_s = (pubkey && cJSON_IsString(pubkey) && pubkey->valuestring) ? pubkey->valuestring : "unknown";
|
||||
const char* id_s = (id && cJSON_IsString(id) && id->valuestring) ? id->valuestring : "";
|
||||
int kind_i = (kind && cJSON_IsNumber(kind)) ? (int)kind->valuedouble : -1;
|
||||
|
||||
const char* tmpl = t->skill_content;
|
||||
if (!tmpl || tmpl[0] == '\0') {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char out[TRIGGER_SKILL_CONTENT_MAX + 256];
|
||||
snprintf(out,
|
||||
sizeof(out),
|
||||
"%s\n\n[event id=%s kind=%d pubkey=%s]\n%s",
|
||||
tmpl,
|
||||
id_s,
|
||||
kind_i,
|
||||
pubkey_s,
|
||||
content_s);
|
||||
|
||||
return strdup(out);
|
||||
}
|
||||
|
||||
static void execute_template_action(trigger_manager_t* mgr,
|
||||
const active_trigger_t* t,
|
||||
cJSON* event,
|
||||
const char* relay_url) {
|
||||
if (!mgr || !mgr->cfg || !t || !event) {
|
||||
return;
|
||||
}
|
||||
|
||||
char* rendered = build_template_output(t, event, relay_url);
|
||||
if (!rendered) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strncmp(rendered, "DM admin:", 9) == 0) {
|
||||
const char* body = rendered + 9;
|
||||
while (*body == ' ') body++;
|
||||
(void)nostr_handler_send_dm(mgr->cfg->admin.pubkey, body);
|
||||
} else if (strncmp(rendered, "POST:", 5) == 0) {
|
||||
const char* body = rendered + 5;
|
||||
while (*body == ' ') body++;
|
||||
(void)nostr_handler_publish_kind_event(1, body, NULL, NULL);
|
||||
} else if (strncmp(rendered, "LOG:", 4) == 0) {
|
||||
const char* body = rendered + 4;
|
||||
while (*body == ' ') body++;
|
||||
DEBUG_INFO("[didactyl] trigger template log (%s): %s", t->skill_slug, body);
|
||||
} else {
|
||||
(void)nostr_handler_send_dm(mgr->cfg->admin.pubkey, rendered);
|
||||
}
|
||||
|
||||
free(rendered);
|
||||
}
|
||||
|
||||
static void execute_llm_action(const active_trigger_t* t, cJSON* event, const char* relay_url) {
|
||||
if (!t || !event) {
|
||||
return;
|
||||
}
|
||||
agent_on_trigger(t->skill_slug, t->skill_content, event, relay_url);
|
||||
}
|
||||
|
||||
static int maybe_fire_trigger_locked(trigger_manager_t* mgr, int index, cJSON* event, const char* relay_url) {
|
||||
active_trigger_t* t = &mgr->triggers[index];
|
||||
if (!t->enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
|
||||
time_t created_ts = (created_at && cJSON_IsNumber(created_at)) ? (time_t)created_at->valuedouble : 0;
|
||||
if (created_ts > 0 && created_ts <= t->last_seen_created_at) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
int cooldown = mgr->cfg->triggers.cooldown_seconds;
|
||||
if (cooldown < 0) cooldown = 0;
|
||||
if (cooldown > 0 && t->last_fired > 0 && (now - t->last_fired) < cooldown) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
t->last_fired = now;
|
||||
if (created_ts > t->last_seen_created_at) {
|
||||
t->last_seen_created_at = created_ts;
|
||||
}
|
||||
|
||||
active_trigger_t trigger_copy = *t;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
if (trigger_copy.action_type == TRIGGER_ACTION_TEMPLATE) {
|
||||
execute_template_action(mgr, &trigger_copy, event, relay_url);
|
||||
} else {
|
||||
execute_llm_action(&trigger_copy, event, relay_url);
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg) {
|
||||
if (!mgr || !cfg) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
memset(mgr, 0, sizeof(*mgr));
|
||||
mgr->cfg = cfg;
|
||||
|
||||
if (pthread_mutex_init(&mgr->mutex, NULL) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int initial_cap = cfg->triggers.max_active > 0 ? cfg->triggers.max_active : TRIGGER_DEFAULT_MAX_ACTIVE;
|
||||
mgr->capacity = initial_cap;
|
||||
mgr->triggers = (active_trigger_t*)calloc((size_t)mgr->capacity, sizeof(active_trigger_t));
|
||||
if (!mgr->triggers) {
|
||||
pthread_mutex_destroy(&mgr->mutex);
|
||||
memset(mgr, 0, sizeof(*mgr));
|
||||
return -1;
|
||||
}
|
||||
|
||||
mgr->last_poll_at = time(NULL);
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger manager initialized (capacity=%d)", mgr->capacity);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
|
||||
if (!mgr || !mgr->cfg) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* adopt_filter = cJSON_CreateObject();
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
cJSON* authors = cJSON_CreateArray();
|
||||
if (!adopt_filter || !kinds || !authors) {
|
||||
cJSON_Delete(adopt_filter);
|
||||
cJSON_Delete(kinds);
|
||||
cJSON_Delete(authors);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10123));
|
||||
cJSON_AddItemToObject(adopt_filter, "kinds", kinds);
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(mgr->cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(adopt_filter, "authors", authors);
|
||||
cJSON_AddNumberToObject(adopt_filter, "limit", 1);
|
||||
|
||||
char* adoption_json = nostr_handler_query_json(adopt_filter, 8000);
|
||||
cJSON_Delete(adopt_filter);
|
||||
if (!adoption_json) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* adoption_events = cJSON_Parse(adoption_json);
|
||||
free(adoption_json);
|
||||
if (!adoption_events || !cJSON_IsArray(adoption_events) || cJSON_GetArraySize(adoption_events) <= 0) {
|
||||
cJSON_Delete(adoption_events);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* list_event = cJSON_GetArrayItem(adoption_events, 0);
|
||||
cJSON* list_tags = list_event ? cJSON_GetObjectItemCaseSensitive(list_event, "tags") : NULL;
|
||||
if (!list_tags || !cJSON_IsArray(list_tags)) {
|
||||
cJSON_Delete(adoption_events);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int loaded = 0;
|
||||
int tn = cJSON_GetArraySize(list_tags);
|
||||
for (int i = 0; i < tn; i++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(list_tags, i);
|
||||
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue;
|
||||
|
||||
cJSON* key = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* val = cJSON_GetArrayItem(tag, 1);
|
||||
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) ||
|
||||
!key->valuestring || !val->valuestring || strcmp(key->valuestring, "a") != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int kind = 0;
|
||||
char pubkey[65] = {0};
|
||||
char slug[65] = {0};
|
||||
if (parse_address_tag(val->valuestring, &kind, pubkey, slug) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* skill_filter = cJSON_CreateObject();
|
||||
cJSON* sk_kinds = cJSON_CreateArray();
|
||||
cJSON* sk_authors = cJSON_CreateArray();
|
||||
cJSON* d_values = cJSON_CreateArray();
|
||||
if (!skill_filter || !sk_kinds || !sk_authors || !d_values) {
|
||||
cJSON_Delete(skill_filter);
|
||||
cJSON_Delete(sk_kinds);
|
||||
cJSON_Delete(sk_authors);
|
||||
cJSON_Delete(d_values);
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(sk_kinds, cJSON_CreateNumber(kind));
|
||||
cJSON_AddItemToObject(skill_filter, "kinds", sk_kinds);
|
||||
cJSON_AddItemToArray(sk_authors, cJSON_CreateString(pubkey));
|
||||
cJSON_AddItemToObject(skill_filter, "authors", sk_authors);
|
||||
cJSON_AddItemToArray(d_values, cJSON_CreateString(slug));
|
||||
cJSON_AddItemToObject(skill_filter, "#d", d_values);
|
||||
cJSON_AddNumberToObject(skill_filter, "limit", 1);
|
||||
|
||||
char* skill_json = nostr_handler_query_json(skill_filter, 8000);
|
||||
cJSON_Delete(skill_filter);
|
||||
if (!skill_json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* skill_events = cJSON_Parse(skill_json);
|
||||
free(skill_json);
|
||||
if (!skill_events || !cJSON_IsArray(skill_events) || cJSON_GetArraySize(skill_events) <= 0) {
|
||||
cJSON_Delete(skill_events);
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* skill_event = cJSON_GetArrayItem(skill_events, 0);
|
||||
cJSON* content = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "content") : NULL;
|
||||
cJSON* tags = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "tags") : NULL;
|
||||
if (!content || !cJSON_IsString(content) || !content->valuestring || !tags || !cJSON_IsArray(tags)) {
|
||||
cJSON_Delete(skill_events);
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* trigger = find_tag_value_string(tags, "trigger");
|
||||
cJSON* filter = find_tag_value_string(tags, "filter");
|
||||
cJSON* action = find_tag_value_string(tags, "action");
|
||||
cJSON* enabled = find_tag_value_string(tags, "enabled");
|
||||
|
||||
const char* trigger_s = (trigger && cJSON_IsString(trigger) && trigger->valuestring) ? trigger->valuestring : NULL;
|
||||
const char* filter_s = (filter && cJSON_IsString(filter) && filter->valuestring) ? filter->valuestring : NULL;
|
||||
const char* action_s = (action && cJSON_IsString(action) && action->valuestring) ? action->valuestring : "llm";
|
||||
const char* enabled_s = (enabled && cJSON_IsString(enabled) && enabled->valuestring) ? enabled->valuestring : "true";
|
||||
|
||||
if (trigger_s && strcmp(trigger_s, "nostr-subscription") == 0 && filter_s && filter_s[0] != '\0') {
|
||||
trigger_action_type_t at = (strcmp(action_s, "template") == 0) ? TRIGGER_ACTION_TEMPLATE : TRIGGER_ACTION_LLM;
|
||||
int is_enabled = (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1;
|
||||
if (trigger_manager_add(mgr, slug, content->valuestring, filter_s, at, is_enabled) == 0) {
|
||||
loaded++;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(skill_events);
|
||||
}
|
||||
|
||||
cJSON_Delete(adoption_events);
|
||||
DEBUG_INFO("[didactyl] trigger manager loaded %d trigger(s) from skills", loaded);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_add(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
int enabled) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0' || !content || !filter_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (action_type != TRIGGER_ACTION_LLM && action_type != TRIGGER_ACTION_TEMPLATE) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
int existing = find_trigger_index_locked(mgr, skill_slug);
|
||||
if (existing >= 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return trigger_manager_update(mgr, skill_slug, content, filter_json, action_type, enabled);
|
||||
}
|
||||
|
||||
int max_active = mgr->cfg ? mgr->cfg->triggers.max_active : TRIGGER_DEFAULT_MAX_ACTIVE;
|
||||
if (max_active < 1) max_active = TRIGGER_DEFAULT_MAX_ACTIVE;
|
||||
if (mgr->count >= max_active) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
DEBUG_WARN("[didactyl] trigger add rejected: max_active reached (%d)", max_active);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (ensure_capacity(mgr, mgr->count + 1) != 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
active_trigger_t* t = &mgr->triggers[mgr->count];
|
||||
memset(t, 0, sizeof(*t));
|
||||
snprintf(t->skill_slug, sizeof(t->skill_slug), "%s", skill_slug);
|
||||
snprintf(t->skill_content, sizeof(t->skill_content), "%s", content);
|
||||
snprintf(t->filter_json, sizeof(t->filter_json), "%s", filter_json);
|
||||
t->action_type = action_type;
|
||||
t->enabled = clamp_enabled(enabled);
|
||||
t->last_fired = 0;
|
||||
t->last_seen_created_at = 0;
|
||||
|
||||
mgr->count++;
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger added slug=%s action=%d enabled=%d", skill_slug, (int)action_type, clamp_enabled(enabled));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
int idx = find_trigger_index_locked(mgr, skill_slug);
|
||||
if (idx < 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (idx < mgr->count - 1) {
|
||||
memmove(&mgr->triggers[idx],
|
||||
&mgr->triggers[idx + 1],
|
||||
(size_t)(mgr->count - idx - 1) * sizeof(active_trigger_t));
|
||||
}
|
||||
|
||||
mgr->count--;
|
||||
memset(&mgr->triggers[mgr->count], 0, sizeof(active_trigger_t));
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger removed slug=%s", skill_slug);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_update(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
int enabled) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0' || !content || !filter_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (action_type != TRIGGER_ACTION_LLM && action_type != TRIGGER_ACTION_TEMPLATE) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
int idx = find_trigger_index_locked(mgr, skill_slug);
|
||||
if (idx < 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return trigger_manager_add(mgr, skill_slug, content, filter_json, action_type, enabled);
|
||||
}
|
||||
|
||||
active_trigger_t* t = &mgr->triggers[idx];
|
||||
snprintf(t->skill_content, sizeof(t->skill_content), "%s", content);
|
||||
snprintf(t->filter_json, sizeof(t->filter_json), "%s", filter_json);
|
||||
t->action_type = action_type;
|
||||
t->enabled = clamp_enabled(enabled);
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger updated slug=%s action=%d enabled=%d", skill_slug, (int)action_type, clamp_enabled(enabled));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_active_count(trigger_manager_t* mgr) {
|
||||
if (!mgr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int active = 0;
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
if (mgr->triggers[i].enabled) {
|
||||
active++;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
return active;
|
||||
}
|
||||
|
||||
int trigger_manager_poll(trigger_manager_t* mgr) {
|
||||
if (!mgr || !mgr->cfg || !mgr->cfg->triggers.enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
if (mgr->last_poll_at > 0 && (now - mgr->last_poll_at) < 2) {
|
||||
return 0;
|
||||
}
|
||||
mgr->last_poll_at = now;
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
int count = mgr->count;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
active_trigger_t snapshot = mgr->triggers[i];
|
||||
if (!snapshot.enabled || snapshot.filter_json[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* filter = cJSON_Parse(snapshot.filter_json);
|
||||
if (!filter || !cJSON_IsObject(filter)) {
|
||||
cJSON_Delete(filter);
|
||||
continue;
|
||||
}
|
||||
|
||||
time_t since = snapshot.last_seen_created_at > 0 ? snapshot.last_seen_created_at + 1 : now - 30;
|
||||
if (since < 0) since = 0;
|
||||
cJSON_AddNumberToObject(filter, "since", (double)since);
|
||||
cJSON_AddNumberToObject(filter, "limit", 8);
|
||||
|
||||
char* events_json = nostr_handler_query_json(filter, 4000);
|
||||
cJSON_Delete(filter);
|
||||
if (!events_json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* events = cJSON_Parse(events_json);
|
||||
free(events_json);
|
||||
if (!events || !cJSON_IsArray(events)) {
|
||||
cJSON_Delete(events);
|
||||
continue;
|
||||
}
|
||||
|
||||
int n = cJSON_GetArraySize(events);
|
||||
for (int e = 0; e < n; e++) {
|
||||
cJSON* ev = cJSON_GetArrayItem(events, e);
|
||||
if (!ev || !cJSON_IsObject(ev)) continue;
|
||||
|
||||
int idx = find_trigger_index_locked(mgr, snapshot.skill_slug);
|
||||
if (idx < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
(void)maybe_fire_trigger_locked(mgr, idx, ev, NULL);
|
||||
}
|
||||
|
||||
cJSON_Delete(events);
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
char* trigger_manager_status_json(trigger_manager_t* mgr) {
|
||||
if (!mgr) {
|
||||
cJSON* err = cJSON_CreateObject();
|
||||
if (!err) {
|
||||
return NULL;
|
||||
}
|
||||
cJSON_AddBoolToObject(err, "success", 0);
|
||||
cJSON_AddStringToObject(err, "error", "trigger manager unavailable");
|
||||
char* out = cJSON_PrintUnformatted(err);
|
||||
cJSON_Delete(err);
|
||||
return out;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON* arr = cJSON_CreateArray();
|
||||
if (!root || !arr) {
|
||||
cJSON_Delete(root);
|
||||
cJSON_Delete(arr);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddNumberToObject(root, "count", mgr->count);
|
||||
|
||||
int active = 0;
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
active_trigger_t* t = &mgr->triggers[i];
|
||||
if (t->enabled) {
|
||||
active++;
|
||||
}
|
||||
|
||||
cJSON* item = cJSON_CreateObject();
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(item, "skill_slug", t->skill_slug);
|
||||
cJSON_AddStringToObject(item, "filter_json", t->filter_json);
|
||||
cJSON_AddStringToObject(item, "action", t->action_type == TRIGGER_ACTION_TEMPLATE ? "template" : "llm");
|
||||
cJSON_AddBoolToObject(item, "enabled", t->enabled ? 1 : 0);
|
||||
cJSON_AddNumberToObject(item, "last_fired", (double)t->last_fired);
|
||||
cJSON_AddNumberToObject(item, "last_seen_created_at", (double)t->last_seen_created_at);
|
||||
cJSON_AddItemToArray(arr, item);
|
||||
}
|
||||
|
||||
cJSON_AddNumberToObject(root, "active", active);
|
||||
cJSON_AddItemToObject(root, "triggers", arr);
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
void trigger_manager_cleanup(trigger_manager_t* mgr) {
|
||||
if (!mgr) {
|
||||
return;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
free(mgr->triggers);
|
||||
mgr->triggers = NULL;
|
||||
mgr->count = 0;
|
||||
mgr->capacity = 0;
|
||||
mgr->cfg = NULL;
|
||||
mgr->last_poll_at = 0;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
pthread_mutex_destroy(&mgr->mutex);
|
||||
memset(mgr, 0, sizeof(*mgr));
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger manager cleaned up");
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#ifndef DIDACTYL_TRIGGER_MANAGER_H
|
||||
#define DIDACTYL_TRIGGER_MANAGER_H
|
||||
|
||||
#include <pthread.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#define TRIGGER_DEFAULT_MAX_ACTIVE 16
|
||||
#define TRIGGER_SKILL_SLUG_MAX 65
|
||||
#define TRIGGER_SKILL_CONTENT_MAX 4096
|
||||
#define TRIGGER_FILTER_JSON_MAX 2048
|
||||
|
||||
typedef enum {
|
||||
TRIGGER_ACTION_LLM = 0,
|
||||
TRIGGER_ACTION_TEMPLATE = 1
|
||||
} trigger_action_type_t;
|
||||
|
||||
typedef struct {
|
||||
char skill_slug[TRIGGER_SKILL_SLUG_MAX];
|
||||
char skill_content[TRIGGER_SKILL_CONTENT_MAX];
|
||||
char filter_json[TRIGGER_FILTER_JSON_MAX];
|
||||
trigger_action_type_t action_type;
|
||||
int enabled;
|
||||
time_t last_fired;
|
||||
time_t last_seen_created_at;
|
||||
} active_trigger_t;
|
||||
|
||||
typedef struct trigger_manager {
|
||||
active_trigger_t* triggers;
|
||||
int count;
|
||||
int capacity;
|
||||
didactyl_config_t* cfg;
|
||||
pthread_mutex_t mutex;
|
||||
time_t last_poll_at;
|
||||
} trigger_manager_t;
|
||||
|
||||
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,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t 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,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
int enabled);
|
||||
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);
|
||||
void trigger_manager_cleanup(trigger_manager_t* mgr);
|
||||
|
||||
#endif
|
||||
Vendored
+28193
File diff suppressed because it is too large
Load Diff
Vendored
+4038
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user