v0.0.30 - Add config-controlled dm_protocol (nip04/nip17/both), NIP-17 receive handling, protocol-aware auto DM routing, and config updates
This commit is contained in:
@@ -90,7 +90,7 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \
|
||||
CURL_LIBS="$(pkg-config --static --libs libcurl)" && \
|
||||
OPENSSL_LIBS="$(pkg-config --static --libs openssl)" && \
|
||||
gcc -static $CFLAGS -Wall -Wextra -std=c99 \
|
||||
-D_GNU_SOURCE -D_DEFAULT_SOURCE -D_POSIX_C_SOURCE=200809L \
|
||||
-D_GNU_SOURCE -D_DEFAULT_SOURCE -D_POSIX_C_SOURCE=200809L -DMG_TLS=MG_TLS_BUILTIN \
|
||||
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
|
||||
-I. -Isrc -Inostr_core_lib -Inostr_core_lib/nostr_core \
|
||||
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
|
||||
|
||||
2
Makefile
2
Makefile
@@ -1,5 +1,5 @@
|
||||
CC = gcc
|
||||
CFLAGS = -std=c99 -Wall -Wextra -Wpedantic -O2 -D_GNU_SOURCE -D_DEFAULT_SOURCE -D_POSIX_C_SOURCE=200809L
|
||||
CFLAGS = -std=c99 -Wall -Wextra -Wpedantic -O2 -D_GNU_SOURCE -D_DEFAULT_SOURCE -D_POSIX_C_SOURCE=200809L -DMG_TLS=MG_TLS_BUILTIN
|
||||
|
||||
SRC_DIR = src
|
||||
TARGET = didactyl
|
||||
|
||||
29
README.md
29
README.md
@@ -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.29
|
||||
## Current Status — v0.0.30
|
||||
|
||||
**Active build — this project is barely working. Experiment at your own risk.**
|
||||
|
||||
> Last release update: v0.0.29 — Update README: current status, runtime context model, project structure, HTTP admin API section, model tools, roadmap checkboxes
|
||||
> Last release update: v0.0.30 — Add config-controlled dm_protocol (nip04/nip17/both), NIP-17 receive handling, protocol-aware auto DM routing, and config updates
|
||||
|
||||
- Connects to configured relays with auto-reconnect and relay state transition logging
|
||||
- Publishes configured startup events per relay as each relay becomes connected
|
||||
@@ -202,6 +202,31 @@ CLI debugger notes:
|
||||
|
||||
Send an encrypted DM to the agent pubkey using any Nostr client (Damus, Amethyst, Primal, etc.): ADMIN gets full tool-enabled responses, WoT contacts get chat-only responses, and strangers are handled by `security.tiers.stranger` + `security.stranger_response`.
|
||||
|
||||
### Chat via local HTTP API (CLI)
|
||||
|
||||
A simple Node.js terminal client is available in [`didactyl-chat-cli.js`](didactyl-chat-cli.js).
|
||||
|
||||
Run it with:
|
||||
|
||||
```bash
|
||||
node ./didactyl-chat-cli.js
|
||||
```
|
||||
|
||||
Optional environment variables:
|
||||
|
||||
- `DIDACTYL_API_BASE_URL` (default: `https://127.0.0.1:8484`)
|
||||
- `DIDACTYL_MODEL` (optional model override)
|
||||
- `DIDACTYL_MAX_TURNS` (default: `4`)
|
||||
- `DIDACTYL_INSECURE_TLS` (default: `1`, set `0` to enforce certificate verification)
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
DIDACTYL_API_BASE_URL=http://127.0.0.1:8484 DIDACTYL_MAX_TURNS=6 node ./didactyl-chat-cli.js
|
||||
```
|
||||
|
||||
The CLI prints each message block with a speaker label (`You` / `Didactyl`) and a blank line between blocks for readability.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
||||
150
chat-didactyl-cli.js
Executable file
150
chat-didactyl-cli.js
Executable file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Simple terminal chat client for Didactyl HTTP API.
|
||||
*
|
||||
* Usage:
|
||||
* node didactyl-chat-cli.js
|
||||
*
|
||||
* Optional env vars:
|
||||
* DIDACTYL_API_BASE_URL=http://127.0.0.1:8484
|
||||
* DIDACTYL_MODEL=claude-haiku-4.5
|
||||
* DIDACTYL_MAX_TURNS=4
|
||||
*/
|
||||
|
||||
const readline = require("node:readline/promises");
|
||||
const { stdin, stdout } = require("node:process");
|
||||
const http = require("node:http");
|
||||
const https = require("node:https");
|
||||
|
||||
const API_BASE_URL = process.env.DIDACTYL_API_BASE_URL || "https://127.0.0.1:8484";
|
||||
const MODEL = process.env.DIDACTYL_MODEL || "";
|
||||
const MAX_TURNS = Number.parseInt(process.env.DIDACTYL_MAX_TURNS || "4", 10);
|
||||
const INSECURE_TLS = !["0", "false", "False", "FALSE"].includes(
|
||||
String(process.env.DIDACTYL_INSECURE_TLS || "1")
|
||||
);
|
||||
|
||||
function printMessage(role, content) {
|
||||
const who = role === "user" ? "You" : role === "assistant" ? "Didactyl" : role;
|
||||
console.log(`${who}>`);
|
||||
console.log(content);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
function postJson(urlString, payload) {
|
||||
const url = new URL(urlString);
|
||||
const isHttps = url.protocol === "https:";
|
||||
const data = JSON.stringify(payload);
|
||||
|
||||
const options = {
|
||||
method: "POST",
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname + url.search,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(data),
|
||||
},
|
||||
};
|
||||
|
||||
if (isHttps) {
|
||||
options.rejectUnauthorized = !INSECURE_TLS;
|
||||
}
|
||||
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = client.request(options, (res) => {
|
||||
let body = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
statusCode: res.statusCode || 0,
|
||||
body,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function callDidactyl(message) {
|
||||
const body = {
|
||||
message,
|
||||
max_turns: Number.isFinite(MAX_TURNS) ? MAX_TURNS : 4,
|
||||
};
|
||||
|
||||
if (MODEL.trim()) {
|
||||
body.model = MODEL.trim();
|
||||
}
|
||||
|
||||
const { statusCode, body: responseBody } = await postJson(`${API_BASE_URL}/api/prompt/agent`, body);
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
throw new Error(`HTTP ${statusCode}: ${responseBody}`);
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(responseBody);
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON from API: ${responseBody}`);
|
||||
}
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || "Didactyl API returned success=false");
|
||||
}
|
||||
|
||||
return String(data.final_response || "");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Didactyl CLI chat");
|
||||
console.log(`API: ${API_BASE_URL}`);
|
||||
console.log(`TLS verify: ${INSECURE_TLS ? "disabled (local dev)" : "enabled"}`);
|
||||
console.log("Type /exit to quit.\n");
|
||||
|
||||
const rl = readline.createInterface({ input: stdin, output: stdout });
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const input = (await rl.question("You> ")).trim();
|
||||
|
||||
if (!input) {
|
||||
console.log("");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input === "/exit" || input === "/quit") {
|
||||
console.log("Exiting.");
|
||||
break;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
|
||||
try {
|
||||
const reply = await callDidactyl(input);
|
||||
printMessage("assistant", reply);
|
||||
console.log("");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Didactyl error: ${message}`);
|
||||
console.error("");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Fatal error: ${message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
"admin": {
|
||||
"pubkey": "admin pubkey"
|
||||
},
|
||||
"dm_protocol": "nip04",
|
||||
"llm": {
|
||||
"provider": "",
|
||||
"api_key": "",
|
||||
@@ -63,8 +64,6 @@
|
||||
"kind": 10002,
|
||||
"content": "",
|
||||
"tags": [
|
||||
|
||||
|
||||
[
|
||||
"r",
|
||||
"wss://relay.damus.io"
|
||||
@@ -81,7 +80,20 @@
|
||||
"r",
|
||||
"ws://127.0.0.1:7777"
|
||||
]
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 10050,
|
||||
"content": "",
|
||||
"tags": [
|
||||
[
|
||||
"relay",
|
||||
"wss://relay.damus.io"
|
||||
],
|
||||
[
|
||||
"relay",
|
||||
"wss://nos.lol"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -105,7 +117,7 @@
|
||||
},
|
||||
{
|
||||
"kind": 31120,
|
||||
"content": "# Didactyl Agent\n\nYou are Didactyl, a sovereign AI agent living on Nostr.\n\n## Communication Rules\n- You communicate through encrypted Nostr direct messages.\n- Keep responses concise and clear.\n\n## Behavior\n- Be helpful and technically accurate.\n- If unsure, state uncertainty directly.\n- Prefer actionable, practical advice.\n- Use the person's name when messaging them if you know it.\n- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n## Tool Use Policy\n- You have tools available and should use them when a request requires taking action.\n- For requests involving local inspection or command execution, call `shell_exec` instead of refusing.\n- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n- After a tool call, base your answer on the actual tool result.\n- Never claim a tool was run if no tool was executed.\n\n## Safety\n- Do not claim to have executed actions you did not execute.\n- You may share your public key (npub) with anyone.\n- Never reveal your private key (nsec) under any circumstance.\n\n---template---\n\n- section: admin_identity\n role: system\n content: |\n ## Administrator Identity (source: config.admin.pubkey)\n\n This is your administrator! Admin pubkey (hex): {{admin_pubkey}}\n\n- section: admin_profile\n role: system\n content: |\n ## Administrator Kind 0 Profile (source: nostr kind 0)\n\n Administrator kind 0 profile content (JSON): {{admin_kind0_json}}\n provider:\n anthropic: |\n <admin_kind0_profile source=\"nostr_kind_0\">\n {{admin_kind0_json}}\n </admin_kind0_profile>\n\n- section: admin_relay_list\n role: system\n content: |\n ## Administrator Relay List (source: nostr kind 10002)\n\n Administrator kind 10002 relay-list content (JSON): {{admin_kind10002_json}}\n\n- section: startup_events\n role: system\n content: |\n ## Startup Events Memory (source: config.startup_events)\n\n Startup events memory (kinds/content/tags): {{startup_events_json}}\n\n- section: adopted_skills\n role: system\n content: |\n {{adopted_skills_content}}\n\n- section: dm_history\n role: expand\n limit: 12\n\n- section: admin_notes\n role: system\n content: |\n ## Administrator Recent Notes (source: nostr kind 1)\n\n {{admin_notes_content}}",
|
||||
"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## Task Management\n- Maintain and use your internal task list as short-term working memory.\n- Break long or complex actions into clear tasks before executing them.\n- Update task status as you complete steps so your plan stays accurate.\n\n## Safety\n- Do not claim to have executed actions you did not execute.\n- You may share your public key (npub) with anyone.\n- Never reveal your private key (nsec) under any circumstance.\n\n---template---\n\n- section: admin_identity\n role: system\n content: |\n ## Administrator Identity (source: config.admin.pubkey)\n\n This is your administrator! Admin pubkey (hex): {{admin_pubkey}}\n\n- section: admin_profile\n role: system\n content: |\n ## Administrator Kind 0 Profile (source: nostr kind 0)\n\n Administrator kind 0 profile content (JSON): {{admin_kind0_json}}\n provider:\n anthropic: |\n <admin_kind0_profile source=\"nostr_kind_0\">\n {{admin_kind0_json}}\n </admin_kind0_profile>\n\n- section: admin_relay_list\n role: system\n content: |\n ## Administrator Relay List (source: nostr kind 10002)\n\n Administrator kind 10002 relay-list content (JSON): {{admin_kind10002_json}}\n\n- section: startup_events\n role: system\n content: |\n ## Startup Events Memory (source: config.startup_events)\n\n Startup events memory (kinds/content/tags): {{startup_events_json}}\n\n- section: adopted_skills\n role: system\n content: |\n {{adopted_skills_content}}\n\n- section: agent_tasks\n role: system\n content: |\n {{tasks_content}}\n\n- section: dm_history\n role: expand\n limit: 12\n\n- section: admin_notes\n role: system\n content: |\n ## Administrator Recent Notes (source: nostr kind 1)\n\n {{admin_notes_content}}",
|
||||
"tags": [
|
||||
[
|
||||
"d",
|
||||
|
||||
8966
context.log.md
Normal file
8966
context.log.md
Normal file
File diff suppressed because one or more lines are too long
57
context_template.md
Normal file
57
context_template.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Context Template
|
||||
|
||||
```yaml
|
||||
- section: admin_identity
|
||||
role: system
|
||||
content: |
|
||||
## Administrator Identity (source: config.admin.pubkey)
|
||||
|
||||
This is your administrator! Admin pubkey (hex): {{admin_pubkey}}
|
||||
|
||||
- section: admin_profile
|
||||
role: system
|
||||
content: |
|
||||
## Administrator Kind 0 Profile (source: nostr kind 0)
|
||||
|
||||
Administrator kind 0 profile content (JSON): {{admin_kind0_json}}
|
||||
provider:
|
||||
anthropic: |
|
||||
<admin_kind0_profile source="nostr_kind_0">
|
||||
{{admin_kind0_json}}
|
||||
</admin_kind0_profile>
|
||||
|
||||
- section: admin_relay_list
|
||||
role: system
|
||||
content: |
|
||||
## Administrator Relay List (source: nostr kind 10002)
|
||||
|
||||
Administrator kind 10002 relay-list content (JSON): {{admin_kind10002_json}}
|
||||
|
||||
- section: startup_events
|
||||
role: system
|
||||
content: |
|
||||
## Startup Events Memory (source: config.startup_events)
|
||||
|
||||
Startup events memory (kinds/content/tags): {{startup_events_json}}
|
||||
|
||||
- section: adopted_skills
|
||||
role: system
|
||||
content: |
|
||||
{{adopted_skills_content}}
|
||||
|
||||
- section: agent_tasks
|
||||
role: system
|
||||
content: |
|
||||
{{tasks_content}}
|
||||
|
||||
- section: dm_history
|
||||
role: expand
|
||||
limit: 12
|
||||
|
||||
- section: admin_notes
|
||||
role: system
|
||||
content: |
|
||||
## Administrator Recent Notes (source: nostr kind 1)
|
||||
|
||||
{{admin_notes_content}}
|
||||
```
|
||||
1272
didactyl.html
Normal file
1272
didactyl.html
Normal file
File diff suppressed because it is too large
Load Diff
57
docs/API.md
57
docs/API.md
@@ -224,9 +224,64 @@ Submit a system prompt and user message for a simple LLM call with no tools. Use
|
||||
|
||||
---
|
||||
|
||||
### POST /api/prompt/agent
|
||||
|
||||
Submit one user message and let Didactyl build full admin context server-side (same context assembly path used for Nostr admin DMs, including admin profile, adopted skills, and recent DM history).
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Tweet about the weather",
|
||||
"model": "claude-haiku-4.5",
|
||||
"max_turns": 5
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `message` | yes | User message string |
|
||||
| `model` | no | Override the current model for this request only |
|
||||
| `max_turns` | no | Maximum tool-call loop iterations (default: 4, max: 16) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"final_response": "Done! I posted a tweet about the weather.",
|
||||
"turns": [
|
||||
{
|
||||
"turn": 1,
|
||||
"tool_calls": [
|
||||
{
|
||||
"name": "nostr_post",
|
||||
"arguments": "{\"kind\":1,\"content\":\"Beautiful day!\"}",
|
||||
"result": "{\"success\":true,\"event_id\":\"abc123\"}"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"model_used": "claude-haiku-4.5",
|
||||
"total_input_tokens_estimate": 3200,
|
||||
"total_output_tokens_estimate": 180
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `final_response` | The LLM final text response after all tool calls complete |
|
||||
| `turns` | Array of turn objects, each containing tool calls made in that turn |
|
||||
| `turns[].tool_calls[]` | Each tool call with name, arguments JSON, and result JSON |
|
||||
| `model_used` | The model that was actually used |
|
||||
| `total_input_tokens_estimate` | Estimated input tokens for the full conversation |
|
||||
| `total_output_tokens_estimate` | Estimated output tokens for the final response |
|
||||
|
||||
---
|
||||
|
||||
### POST /api/prompt/run
|
||||
|
||||
Submit a full messages array with the agent tool set enabled. The agent executes tool calls in a loop and returns the complete conversation trace.
|
||||
Submit a full messages array with the agent tool set enabled. This endpoint runs exactly what you provide and does **not** auto-build Didactyl admin context.
|
||||
|
||||
**Request:**
|
||||
|
||||
|
||||
71
local_test_servers.py
Normal file
71
local_test_servers.py
Normal file
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import ssl
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
HTTP_PORT = 9080
|
||||
HTTPS_PORT = 9449
|
||||
CERT = "/home/teknari/.ssl_for_local_servers/cert.pem"
|
||||
KEY = "/home/teknari/.ssl_for_local_servers/key.pem"
|
||||
|
||||
|
||||
HTML_FILE = Path("./didactyl.html")
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def _cors(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.send_header("Access-Control-Allow-Private-Network", "true")
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self._cors()
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
if self.path in ("/", "/didactyl.html") and HTML_FILE.exists():
|
||||
body = HTML_FILE.read_bytes()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self._cors()
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
|
||||
body = json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"name": "python-test",
|
||||
"path": self.path,
|
||||
"scheme_hint": "https" if self.server.server_port == HTTPS_PORT else "http",
|
||||
}
|
||||
).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self._cors()
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print(f"[{self.server.server_port}] " + (fmt % args), flush=True)
|
||||
|
||||
|
||||
httpd = HTTPServer((HOST, HTTP_PORT), Handler)
|
||||
httpsd = HTTPServer((HOST, HTTPS_PORT), Handler)
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.load_cert_chain(certfile=CERT, keyfile=KEY)
|
||||
httpsd.socket = ctx.wrap_socket(httpsd.socket, server_side=True)
|
||||
|
||||
print(f"HTTP listening on http://{HOST}:{HTTP_PORT}", flush=True)
|
||||
print(f"HTTPS listening on https://{HOST}:{HTTPS_PORT}", flush=True)
|
||||
print("Use Ctrl+C to stop", flush=True)
|
||||
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
httpsd.serve_forever()
|
||||
215
plans/agent_tasks.md
Normal file
215
plans/agent_tasks.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# Agent Tasks: Short-Term Memory via Context-Injected Task List
|
||||
|
||||
## Summary
|
||||
|
||||
Add a **tasks** system that serves as the agent's short-term working memory. The agent can break down goals into steps, track progress, and see its current task list in every prompt context. Tasks are file-backed (not stored on Nostr) and managed via a dedicated `task_manage` tool.
|
||||
|
||||
## How It Works
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User sends message] --> B[Context builder runs]
|
||||
B --> C[Template resolver hits tasks_content variable]
|
||||
C --> D[Read tasks.json from disk]
|
||||
D --> E{Tasks exist?}
|
||||
E -->|Yes| F[Format tasks as readable text]
|
||||
E -->|No| G[Return empty string - section skipped]
|
||||
F --> H[Inject as system message in prompt]
|
||||
G --> H
|
||||
H --> I[LLM sees current tasks in context]
|
||||
I --> J{LLM decides to update tasks?}
|
||||
J -->|Yes| K[LLM calls task_manage tool]
|
||||
K --> L[Tool updates tasks.json on disk]
|
||||
L --> M[Tool result returned to LLM]
|
||||
J -->|No| N[LLM responds normally]
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
### Storage: `tasks.json`
|
||||
|
||||
A simple JSON file in the agent's working directory. Structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Query admin relay list to find active relays",
|
||||
"status": "done",
|
||||
"created_at": 1709535600,
|
||||
"updated_at": 1709535660
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"text": "Draft long-form article about Nostr relay setup",
|
||||
"status": "active",
|
||||
"created_at": 1709535600,
|
||||
"updated_at": 1709535600
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"text": "Publish article as kind 30023",
|
||||
"status": "pending",
|
||||
"created_at": 1709535600,
|
||||
"updated_at": 1709535600
|
||||
}
|
||||
],
|
||||
"next_id": 4
|
||||
}
|
||||
```
|
||||
|
||||
Task statuses: `pending`, `active`, `done`
|
||||
|
||||
### Tool: `task_manage`
|
||||
|
||||
A single tool with an `action` parameter that covers all operations:
|
||||
|
||||
| Action | Parameters | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `list` | *(none)* | Return all tasks with status |
|
||||
| `add` | `text`, optional `status` | Add a new task, default status `pending` |
|
||||
| `update` | `id`, optional `text`, optional `status` | Update text and/or status of a task |
|
||||
| `remove` | `id` | Remove a task by ID |
|
||||
| `clear` | optional `status` | Remove all tasks, or all with a given status |
|
||||
| `replace` | `tasks` (array of text strings) | Replace entire task list with new items |
|
||||
|
||||
The `replace` action is important — it lets the LLM rewrite the whole plan in one call rather than doing add/remove/update one at a time. This is the most common pattern: the agent works out a plan and writes all steps at once.
|
||||
|
||||
**Tool schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "task_manage",
|
||||
"description": "Manage the agent task list - short-term working memory for tracking steps in a plan. Tasks appear in your context on every message.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["list", "add", "update", "remove", "clear", "replace"]
|
||||
},
|
||||
"text": { "type": "string" },
|
||||
"id": { "type": "integer" },
|
||||
"status": { "type": "string", "enum": ["pending", "active", "done"] },
|
||||
"tasks": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Context Section: `agent_tasks`
|
||||
|
||||
New section in the context template, placed after `adopted_skills` and before `dm_history`:
|
||||
|
||||
```yaml
|
||||
- section: agent_tasks
|
||||
role: system
|
||||
skip_if_empty: true
|
||||
content: |
|
||||
{{tasks_content}}
|
||||
```
|
||||
|
||||
### Template Variable: `{{tasks_content}}`
|
||||
|
||||
New resolver in `agent_template_resolve_var()` that:
|
||||
|
||||
1. Reads `tasks.json` from the working directory
|
||||
2. Parses the JSON
|
||||
3. Formats active/pending tasks as readable text
|
||||
4. Returns empty string if no tasks exist (section gets skipped via `skip_if_empty`)
|
||||
|
||||
**Rendered format in context:**
|
||||
|
||||
```
|
||||
### Current Tasks
|
||||
|
||||
Your active task list - short-term working memory for tracking plan steps.
|
||||
|
||||
- [x] 1. Query admin relay list to find active relays
|
||||
- [-] 2. Draft long-form article about Nostr relay setup
|
||||
- [ ] 3. Publish article as kind 30023
|
||||
```
|
||||
|
||||
Legend: `[x]` = done, `[-]` = active, `[ ]` = pending
|
||||
|
||||
Done tasks are included so the agent has continuity about what it already accomplished, but they could be pruned after a configurable count or age to save tokens.
|
||||
|
||||
### System Prompt Addition
|
||||
|
||||
Add to the agent's behavioral rules in the soul/system prompt:
|
||||
|
||||
```
|
||||
### Task Management
|
||||
- You have a task list that serves as your short-term working memory.
|
||||
- When working on multi-step goals, use task_manage to track your plan.
|
||||
- Update task status as you complete steps.
|
||||
- Your current tasks appear in your context automatically.
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Add `task_manage` tool implementation in `tools.c`
|
||||
|
||||
- New `execute_task_manage()` function
|
||||
- Reads/writes `tasks.json` in the working directory (uses `build_tool_path` for sandboxing)
|
||||
- Handles all 6 actions: list, add, update, remove, clear, replace
|
||||
- Returns JSON result with success/failure and current task list
|
||||
|
||||
### 2. Register `task_manage` tool schema in `tools_build_openai_schema_json()`
|
||||
|
||||
- Add tool definition (t35 or next available) with the schema above
|
||||
|
||||
### 3. Wire `task_manage` into `tools_execute()` dispatch
|
||||
|
||||
- Add `strcmp(tool_name, "task_manage")` branch calling `execute_task_manage()`
|
||||
|
||||
### 4. Add `{{tasks_content}}` template variable resolver in `agent.c`
|
||||
|
||||
- New `build_tasks_content_string()` function
|
||||
- Reads `tasks.json`, formats as markdown checklist
|
||||
- Add to `agent_template_resolve_var()` for var name `tasks_content`
|
||||
|
||||
### 5. Add `agent_tasks` section to context template
|
||||
|
||||
- Add the new section in `context_template.md`
|
||||
- Place after `adopted_skills`, before `dm_history`
|
||||
- Use `skip_if_empty: true` so it costs zero tokens when no tasks exist
|
||||
|
||||
### 6. Add section detection for context logging
|
||||
|
||||
- Add `agent_tasks` detection in `detect_context_section()` in `agent.c`
|
||||
|
||||
### 7. Add task management guidance to system prompt
|
||||
|
||||
- Brief behavioral instruction so the agent knows when/how to use the task list
|
||||
|
||||
## Token Budget Considerations
|
||||
|
||||
- Empty task list: **0 tokens** (skipped via `skip_if_empty`)
|
||||
- Typical 5-task plan: **~80-120 tokens**
|
||||
- Maximum reasonable list of 15 tasks: **~250-350 tokens**
|
||||
- Consider pruning done tasks older than N turns or keeping only the last M done tasks
|
||||
|
||||
## Future: User-Facing To-Do List (Nostr)
|
||||
|
||||
This is explicitly **not** the user-facing to-do list. That future feature would:
|
||||
- Store items as Nostr events (likely a NIP-51 style list or custom kind)
|
||||
- Be visible to the user via Nostr clients
|
||||
- Have its own separate tool (`todo_manage` or similar)
|
||||
- Potentially reference agent tasks that graduate to user-visible items
|
||||
|
||||
The agent tasks system is purely internal working memory.
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/tools.c` | Add `execute_task_manage()`, tool schema, dispatch entry |
|
||||
| `src/agent.c` | Add `build_tasks_content_string()`, resolver entry, section detection |
|
||||
| `context_template.md` | Add `agent_tasks` section |
|
||||
| Soul/system prompt (kind 31120) | Add task management behavioral guidance |
|
||||
167
plans/context_optimization.md
Normal file
167
plans/context_optimization.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Context Optimization Plan
|
||||
|
||||
Analysis of [`context.log.md`](../context.log.md) (13,609 bytes / ~3,402 tokens across 20 sections) and [`context_template.md`](../context_template.md).
|
||||
|
||||
## Issues Found
|
||||
|
||||
### 1. Massive Duplication in `startup_events` Section
|
||||
|
||||
The **startup_events** section (line 74-80 in the log) dumps the *entire* `config.startup_events` array as raw JSON — including the full soul/system prompt (kind 31120) which is already sent verbatim as the **system_prompt** section. The soul text appears **twice** in every request.
|
||||
|
||||
**Estimated waste:** ~1,500-2,000 tokens per request.
|
||||
|
||||
**Fix:** Filter out kind 31120 (soul) from the startup_events JSON blob, or better yet, only include kinds the model actually needs to reference (kind 0 profile, kind 10002 relay list, kind 3 contacts). The soul is already the system prompt — repeating it as data is pure waste.
|
||||
|
||||
### 2. Duplicate Startup Messages in DM History
|
||||
|
||||
The DM history contains **four separate** `Didactyl has started up and is online (version v0.0.29, connected relays: 4/4).` assistant messages (lines 123-127, 130-134, 144-148, 222-226, 245-249). These are startup announcement DMs that got stored as separate events. The model sees the same boilerplate startup message repeated across the conversation.
|
||||
|
||||
**Estimated waste:** ~200-300 tokens.
|
||||
|
||||
**Fix:** Deduplicate consecutive identical assistant messages in the DM history builder, or filter out startup announcement messages (they carry no conversational value).
|
||||
|
||||
### 3. Skills Rendered as Raw JSON Instead of Structured Text
|
||||
|
||||
Skill instructions at lines 97-118 are dumped as raw JSON objects. Models parse structured natural language far more reliably than nested JSON. The `content_fields` serialization format wastes tokens on JSON syntax characters and key quoting.
|
||||
|
||||
**Estimated waste:** ~100-200 tokens of JSON overhead per skill, plus reduced comprehension quality.
|
||||
|
||||
**Fix:** When serializing `content_fields`-based skills for context, flatten them into readable text:
|
||||
```
|
||||
Skill: long_form_note
|
||||
Description: How to publish a NIP-23 long-form article (kind 30023)
|
||||
NIP: NIP-23
|
||||
Event Kind: 30023
|
||||
Format: The content field must be markdown text...
|
||||
Required Tags:
|
||||
- d: Addressable identifier slug...
|
||||
- title: Human-readable article title
|
||||
- published_at: Unix timestamp as string...
|
||||
Procedure:
|
||||
1. Determine title and d tag...
|
||||
2. Draft markdown body content...
|
||||
```
|
||||
|
||||
### 4. Empty Sections Still Sent
|
||||
|
||||
The **admin_relay_list** section (line 65-71) has no data — the JSON value is empty. Sending an empty section wastes tokens on the header/framing with no informational value.
|
||||
|
||||
**Estimated waste:** ~30-40 tokens.
|
||||
|
||||
**Fix:** Skip sections where the resolved variable is empty or whitespace-only.
|
||||
|
||||
### 5. Admin Identity Could Be Merged with Admin Profile
|
||||
|
||||
The **admin_identity** section (line 47-53) sends just the hex pubkey, then **admin_profile** (line 56-62) sends the full kind 0 JSON which implicitly identifies the admin. These could be a single section.
|
||||
|
||||
**Estimated savings:** ~40-50 tokens of framing overhead.
|
||||
|
||||
### 6. `admin_notes` Placement Breaks Conversation Flow
|
||||
|
||||
In the template, `admin_notes` is placed *after* `dm_history` (expand). In the actual log, this means a system message appears sandwiched between DM history messages (line 252, between assistant messages and the final user message at line 274). This breaks the natural conversation flow and may confuse the model about message ordering.
|
||||
|
||||
**Fix:** Move `admin_notes` *before* `dm_history` in the template so all system context is grouped together before the conversation begins.
|
||||
|
||||
### 7. No Agent Self-Identity Section
|
||||
|
||||
The model knows it is Didactyl from the system prompt, but there is no section telling it its own pubkey/npub. The admin pubkey is provided but the agent's own key is not in the context (it is only available via the `nostr_pubkey` tool). Adding a small self-identity section would let the model reference its own key without a tool call.
|
||||
|
||||
**Estimated cost:** ~20-30 tokens.
|
||||
|
||||
## Priority Summary
|
||||
|
||||
| Priority | Issue | Token Savings | Complexity |
|
||||
|----------|-------|---------------|------------|
|
||||
| **P0** | Soul duplicated in startup_events | ~1,500-2,000 | Low — filter kind 31120 from startup blob |
|
||||
| **P1** | Duplicate startup DMs in history | ~200-300 | Medium — dedup logic in history builder |
|
||||
| **P1** | Skills as raw JSON | ~100-200 + quality | Medium — flatten content_fields to text |
|
||||
| **P2** | Empty sections still sent | ~30-40 | Low — skip empty resolved vars |
|
||||
| **P2** | admin_notes after dm_history | 0 (quality) | Low — reorder template |
|
||||
| **P3** | Merge admin_identity + admin_profile | ~40-50 | Low — template change |
|
||||
| **P3** | Add agent self-identity section | -20-30 (adds) | Low — new template var |
|
||||
|
||||
## Bug: Kind 10002 Relay List Is Always Empty
|
||||
|
||||
At [`nostr_handler.c:705`](../src/nostr_handler.c:705) the kind 10002 handler stores `content->valuestring`, but NIP-65 relay list events have an **empty content field** — the relay URLs live in the **tags** as `["r", "wss://relay.example.com"]` entries. So `g_admin_kind10002_json` is always `""`.
|
||||
|
||||
**Fix:** Parse the `"r"` tags from the kind 10002 event and serialize them as a JSON array of relay URL strings (or plain-text list).
|
||||
|
||||
## Sender Verification Status
|
||||
|
||||
The [`tier`](../src/nostr_handler.h:8) enum (`DIDACTYL_SENDER_ADMIN`, `DIDACTYL_SENDER_WOT`, `DIDACTYL_SENDER_STRANGER`) is already resolved before [`agent_on_message()`](../src/agent.c:1453) is called, but it is **not passed into the context builder**. The model has no way to know whether the current message was cryptographically verified as coming from the administrator vs. a web-of-trust contact.
|
||||
|
||||
**Fix:** Pass the sender tier into the context builder and expose it as a template variable (e.g. `{{sender_verification}}`) that resolves to text like:
|
||||
- `"This message has been cryptographically verified as coming from your administrator."`
|
||||
- `"This message is from a web-of-trust contact (not the administrator)."`
|
||||
|
||||
## Proposed Optimized Template
|
||||
|
||||
```yaml
|
||||
- section: agent_identity
|
||||
role: system
|
||||
content: |
|
||||
Agent Identity
|
||||
Your pubkey (hex): {{agent_pubkey}}
|
||||
|
||||
- section: sender_context
|
||||
role: system
|
||||
content: |
|
||||
{{sender_verification}}
|
||||
|
||||
- section: admin_context
|
||||
role: system
|
||||
content: |
|
||||
Administrator Context
|
||||
|
||||
Pubkey (hex): {{admin_pubkey}}
|
||||
{{admin_profile_plain}}
|
||||
{{admin_relay_list_plain}}
|
||||
|
||||
- section: startup_events
|
||||
role: system
|
||||
skip_if_empty: true
|
||||
content: |
|
||||
Startup Events Memory
|
||||
{{startup_events_json}}
|
||||
|
||||
- section: adopted_skills
|
||||
role: system
|
||||
skip_if_empty: true
|
||||
content: |
|
||||
{{adopted_skills_content}}
|
||||
|
||||
- section: admin_notes
|
||||
role: system
|
||||
skip_if_empty: true
|
||||
content: |
|
||||
Administrator Recent Notes (source: nostr kind 1)
|
||||
{{admin_notes_content}}
|
||||
|
||||
- section: dm_history
|
||||
role: expand
|
||||
limit: 12
|
||||
```
|
||||
|
||||
Key changes from current template:
|
||||
- **No markdown headers** in system sections — plain English throughout
|
||||
- **Merged admin section** combines identity, profile, and relay list
|
||||
- **`{{admin_profile_plain}}`** — new variable that renders kind 0 JSON as readable text (e.g. `Name: WSB, About: ...`)
|
||||
- **`{{admin_relay_list_plain}}`** — new variable that renders relay URLs from tags as a plain list
|
||||
- **`{{sender_verification}}`** — new variable stating cryptographic verification status
|
||||
- **`admin_notes` moved before `dm_history`** so all system context is grouped before conversation
|
||||
- **`skip_if_empty`** prevents sending empty sections
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Fix kind 10002 relay list bug** — extract relay URLs from tags instead of content in [`nostr_handler.c:705`](../src/nostr_handler.c:705)
|
||||
2. **Filter kind 31120** from `startup_events_json` variable resolver in [`agent.c`](../src/agent.c)
|
||||
3. **Deduplicate consecutive identical messages** in DM history builder
|
||||
4. **Flatten `content_fields` JSON skills** into readable text format
|
||||
5. **Add `skip_if_empty` support** to template engine (skip section when resolved content is blank)
|
||||
6. **Reorder template** — move `admin_notes` before `dm_history`
|
||||
7. **Add `agent_pubkey` template variable** and agent identity section
|
||||
8. **Merge admin sections** — combine identity + profile (plain English) + relay list into one section
|
||||
9. **Add `admin_profile_plain` variable** — parse kind 0 JSON into readable text
|
||||
10. **Add `admin_relay_list_plain` variable** — parse kind 10002 tags into relay URL list
|
||||
11. **Pass sender tier to context builder** and add `sender_verification` template variable
|
||||
12. **Remove markdown formatting** from system section content — use plain English
|
||||
194
plans/nip17_messaging.md
Normal file
194
plans/nip17_messaging.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# NIP-17 Messaging Implementation Plan
|
||||
|
||||
## Background
|
||||
|
||||
Didactyl currently uses NIP-04 (kind 4) for all DM communication. NIP-17 send support exists via `nostr_handler_send_dm_nip17()` and the `nostr_dm_send_nip17` tool, but there is **no ability to receive NIP-17 messages** and **all agent responses always use NIP-04**.
|
||||
|
||||
The `nostr_core_lib` already has full NIP-17/NIP-59 support including:
|
||||
- `nostr_nip17_create_chat_event()` — create kind 14 rumor
|
||||
- `nostr_nip17_send_dm()` — seal + gift wrap (creates wraps for both recipient AND sender)
|
||||
- `nostr_nip17_receive_dm()` — unwrap gift wrap, unseal rumor, return kind 14
|
||||
- `nostr_nip17_extract_dm_relays()` — parse kind 10050 relay lists
|
||||
|
||||
## Config-Driven Protocol Selection
|
||||
|
||||
### New Config Field
|
||||
|
||||
Add a `dm_protocol` field to the top-level config:
|
||||
|
||||
```json
|
||||
{
|
||||
"dm_protocol": "nip04",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Valid values:
|
||||
- `"nip04"` — NIP-04 only (current behavior, default for backward compatibility)
|
||||
- `"nip17"` — NIP-17 only (subscribe to kind 1059, send via gift wrap)
|
||||
- `"both"` — Subscribe to both kind 4 and kind 1059; reply using whichever protocol the message arrived on
|
||||
|
||||
### Config Struct Change
|
||||
|
||||
In `config.h`, add to `didactyl_config_t`:
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
DM_PROTOCOL_NIP04 = 0,
|
||||
DM_PROTOCOL_NIP17 = 1,
|
||||
DM_PROTOCOL_BOTH = 2
|
||||
} dm_protocol_t;
|
||||
```
|
||||
|
||||
Add `dm_protocol_t dm_protocol;` to the config struct.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Add `dm_protocol` config parsing
|
||||
|
||||
**Files**: `config.h`, `config.c`
|
||||
|
||||
- Add `dm_protocol_t` enum and field to `didactyl_config_t`
|
||||
- Parse `"dm_protocol"` string from JSON in `config_load()`
|
||||
- Default to `DM_PROTOCOL_NIP04` if not specified
|
||||
|
||||
### 2. Add kind 1059 subscription
|
||||
|
||||
**File**: `nostr_handler.c` — `nostr_handler_subscribe_dms()`
|
||||
|
||||
- When `dm_protocol` is `NIP17` or `BOTH`, add `kind: 1059` to the subscription filter
|
||||
- When `dm_protocol` is `NIP04` or `BOTH`, keep `kind: 4` in the filter
|
||||
- The subscription filter becomes `kinds: [4, 1059]` for `BOTH` mode
|
||||
|
||||
### 3. Add NIP-17 receive handling in `on_event()`
|
||||
|
||||
**File**: `nostr_handler.c` — `on_event()`
|
||||
|
||||
Currently `on_event()` rejects anything that is not kind 4. Update to:
|
||||
|
||||
- If kind == 1059: unwrap gift wrap via `nostr_nip17_receive_dm()`, extract sender pubkey from the kind 14 rumor, extract message content, determine sender tier, fire `g_dm_callback`
|
||||
- If kind == 4: existing NIP-04 decrypt path (unchanged)
|
||||
- Track which protocol was used per sender pubkey for reply routing
|
||||
|
||||
### 4. Track protocol per sender for reply routing
|
||||
|
||||
**File**: `nostr_handler.c`
|
||||
|
||||
Add a small cache that maps `sender_pubkey_hex -> last_protocol_used`:
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
char pubkey_hex[65];
|
||||
dm_protocol_t protocol;
|
||||
} sender_protocol_entry_t;
|
||||
|
||||
#define SENDER_PROTOCOL_CACHE_SIZE 64
|
||||
static sender_protocol_entry_t g_sender_protocol_cache[SENDER_PROTOCOL_CACHE_SIZE];
|
||||
```
|
||||
|
||||
When a DM arrives via kind 4, record `NIP04` for that sender. When via kind 1059, record `NIP17`.
|
||||
|
||||
### 5. Add protocol-aware send function
|
||||
|
||||
**File**: `nostr_handler.c`
|
||||
|
||||
Add a new function that routes based on config + sender history:
|
||||
|
||||
```c
|
||||
int nostr_handler_send_dm_auto(const char* recipient_pubkey_hex, const char* message);
|
||||
```
|
||||
|
||||
Logic:
|
||||
- If `dm_protocol == NIP04`: always use `nostr_handler_send_dm()`
|
||||
- If `dm_protocol == NIP17`: always use `nostr_handler_send_dm_nip17()`
|
||||
- If `dm_protocol == BOTH`: check sender protocol cache; use matching protocol, default to NIP-04
|
||||
|
||||
Expose in `nostr_handler.h`.
|
||||
|
||||
### 6. Update agent.c to use auto-routing
|
||||
|
||||
**File**: `agent.c`
|
||||
|
||||
Replace all `nostr_handler_send_dm()` calls with `nostr_handler_send_dm_auto()`. This is a mechanical find-and-replace across ~15 call sites.
|
||||
|
||||
### 7. Add kind 10050 startup event support
|
||||
|
||||
**File**: `config.json.example`
|
||||
|
||||
Add a kind 10050 startup event example so NIP-17 clients can discover the agent's DM relay preferences:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 10050,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["relay", "wss://relay.damus.io"],
|
||||
["relay", "wss://nos.lol"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**File**: `config.c` — startup event parsing already handles arbitrary kinds, so this should work with no code changes.
|
||||
|
||||
### 8. Update startup DM to use auto-routing
|
||||
|
||||
**File**: `main.c`
|
||||
|
||||
The startup status DM at line 255 currently calls `nostr_handler_send_dm()`. Update to `nostr_handler_send_dm_auto()`.
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Config
|
||||
CFG[dm_protocol setting]
|
||||
CFG -->|nip04| NIP04_MODE[NIP-04 only]
|
||||
CFG -->|nip17| NIP17_MODE[NIP-17 only]
|
||||
CFG -->|both| BOTH_MODE[Both protocols]
|
||||
end
|
||||
|
||||
subgraph Subscription
|
||||
SUB[nostr_handler_subscribe_dms]
|
||||
NIP04_MODE --> SUB_K4[Subscribe kind 4]
|
||||
NIP17_MODE --> SUB_K1059[Subscribe kind 1059]
|
||||
BOTH_MODE --> SUB_BOTH[Subscribe kind 4 + 1059]
|
||||
end
|
||||
|
||||
subgraph Receive Path
|
||||
EVT[on_event callback]
|
||||
EVT -->|kind 4| DEC4[NIP-04 decrypt]
|
||||
EVT -->|kind 1059| DEC17[NIP-17 unwrap + unseal]
|
||||
DEC4 --> CACHE[Record sender protocol]
|
||||
DEC17 --> CACHE
|
||||
CACHE --> CB[Fire dm_callback]
|
||||
end
|
||||
|
||||
subgraph Send Path
|
||||
SEND[nostr_handler_send_dm_auto]
|
||||
SEND -->|config=nip04| S4[nostr_handler_send_dm - kind 4]
|
||||
SEND -->|config=nip17| S17[nostr_handler_send_dm_nip17 - kind 1059]
|
||||
SEND -->|config=both| LOOKUP[Check sender protocol cache]
|
||||
LOOKUP -->|sender used nip04| S4
|
||||
LOOKUP -->|sender used nip17| S17
|
||||
LOOKUP -->|unknown| S4
|
||||
end
|
||||
```
|
||||
|
||||
## File Change Summary
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `config.h` | Add `dm_protocol_t` enum and field |
|
||||
| `config.c` | Parse `dm_protocol` from JSON |
|
||||
| `nostr_handler.h` | Add `nostr_handler_send_dm_auto()` declaration |
|
||||
| `nostr_handler.c` | Add kind 1059 subscription, NIP-17 receive in `on_event()`, sender protocol cache, `nostr_handler_send_dm_auto()` |
|
||||
| `agent.c` | Replace `nostr_handler_send_dm()` with `nostr_handler_send_dm_auto()` |
|
||||
| `main.c` | Replace startup DM send with `nostr_handler_send_dm_auto()` |
|
||||
| `config.json.example` | Add `dm_protocol` field and kind 10050 startup event example |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **NIP-04 mode** (default): Verify existing behavior is unchanged
|
||||
2. **NIP-17 mode**: Send a NIP-17 DM from a client like Amethyst/0xchat, verify Didactyl receives and replies via NIP-17
|
||||
3. **Both mode**: Send NIP-04 DM, verify NIP-04 reply; send NIP-17 DM, verify NIP-17 reply
|
||||
4. **Kind 10050**: Verify the relay list event is published on startup and discoverable by NIP-17 clients
|
||||
360
plans/tool_orchestration.md
Normal file
360
plans/tool_orchestration.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# Tool Orchestration Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This plan covers four related features:
|
||||
1. **Slash commands** — direct tool execution bypassing the LLM
|
||||
2. **Skill-forward execution (`/run` + `skill_run`)** — one-shot skill invocation with LLM, including external skill sharing
|
||||
3. **Skill-tool maturity levels** — skills that register as callable tools with promotion workflow
|
||||
4. **Deterministic step executor** — hardened skills that run without LLM involvement
|
||||
|
||||
## 1. Slash Commands (Direct Tool Execution)
|
||||
|
||||
### Behavior
|
||||
When a message starts with `/`, bypass the LLM entirely and execute the tool directly.
|
||||
|
||||
```
|
||||
/shell_exec {"command": "ls -la"}
|
||||
/nostr_query {"filter": {"kinds": [1], "limit": 5}}
|
||||
/nostr_nip05_lookup {"identifier": "jack@cash.app"}
|
||||
```
|
||||
|
||||
### Parsing Rules
|
||||
- `/tool_name` — call tool with empty args `{}`
|
||||
- `/tool_name {"key": "value"}` — call tool with JSON args
|
||||
- `/tool_name plain text` — wrap as `{"input": "plain text"}` (convenience)
|
||||
- `/help` — list available tools
|
||||
- `/help tool_name` — show tool schema
|
||||
|
||||
### Implementation
|
||||
1. In [`agent_on_message()`](../src/agent.c:1453), check if `message[0] == '/'`
|
||||
2. Parse tool name (everything between `/` and first space or end)
|
||||
3. Parse args (everything after tool name, try JSON first, fall back to string wrapper)
|
||||
4. Call [`tools_execute()`](../src/tools.c) directly
|
||||
5. Send result as DM — no LLM round-trip
|
||||
6. Still log to context.log.md with `phase=direct_tool_exec`
|
||||
|
||||
### Special Slash Commands
|
||||
- `/help` — list available tools
|
||||
- `/help tool_name` — show tool schema
|
||||
- `/run` — skill-forward execution (see section 2)
|
||||
|
||||
### Security
|
||||
- Only admin tier can use slash commands (same as current tool policy)
|
||||
- Slash commands respect the same `tools.enabled` and `security.admin.tools_enabled` config flags
|
||||
|
||||
## 2. Skill-Forward Execution
|
||||
|
||||
### The Problem
|
||||
|
||||
Today, skills are passive — they're injected into the LLM context via [`append_adopted_skills_context()`](../src/agent.c:1418) and the LLM decides when they're relevant. There's no way to say "run this specific skill right now" and there's no way to try someone else's skill without permanently adopting it.
|
||||
|
||||
### Three Invocation Layers
|
||||
|
||||
| Layer | How it works | LLM? | Persists? |
|
||||
|-------|-------------|------|-----------|
|
||||
| **Passive/adopted** | Skill instructions injected into every conversation context | Yes, LLM decides relevance | Yes — in kind 10123 adoption list |
|
||||
| **Skill-forward** | Skill instructions become the primary system prompt; LLM executes them | Yes, but constrained | No — one-shot execution |
|
||||
| **Hardened** | Deterministic step executor, no LLM | No | Yes — adopted skill with `execution: hardened` |
|
||||
|
||||
### Entry Points
|
||||
|
||||
#### A. `/run` slash command (admin direct invocation)
|
||||
|
||||
```
|
||||
/run deploy-website staging # run own adopted skill by slug
|
||||
/run 31123:<pubkey>:deploy-website staging # run anyone's skill by address
|
||||
/run deploy-website {"target": "production"} # JSON args
|
||||
```
|
||||
|
||||
Parsing:
|
||||
1. First token after `/run` is the skill identifier (slug or `kind:pubkey:slug` address)
|
||||
2. Everything after is args (try JSON first, fall back to `{"input": "plain text"}`)
|
||||
|
||||
#### B. `skill_run` tool (LLM-mediated invocation)
|
||||
|
||||
The LLM can invoke skills on behalf of the admin during conversation:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "skill_run",
|
||||
"description": "Fetch and execute a skill one-shot without adopting it. Works with own adopted skills by slug or any public skill by address.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": { "type": "string", "description": "Skill slug for own adopted skills" },
|
||||
"address": { "type": "string", "description": "Full skill address: kind:pubkey:slug" },
|
||||
"pubkey": { "type": "string", "description": "Author pubkey, used with slug to form address" },
|
||||
"args": { "type": "string", "description": "Arguments or context to pass to the skill" },
|
||||
"sandbox": { "type": "boolean", "description": "Override sandbox setting. Default: true for external, false for own skills" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This enables natural conversation like:
|
||||
> "My friend @jack just published a skill called summarize-thread. Try it on the latest thread in my feed."
|
||||
|
||||
The agent would `skill_search` to find it, then `skill_run` to execute it.
|
||||
|
||||
### Execution Flow
|
||||
|
||||
Both `/run` and `skill_run` use the same underlying executor:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[/run or skill_run called] --> B{Skill identifier type?}
|
||||
B -->|slug only| C[Look up in adopted skills cache]
|
||||
B -->|address or pubkey+slug| D[Fetch skill event from Nostr]
|
||||
C --> E{Found?}
|
||||
D --> E
|
||||
E -->|No| F[Return error: skill not found]
|
||||
E -->|Yes| G{External skill?}
|
||||
G -->|Yes| H[Apply sandbox - restrict tools]
|
||||
G -->|No| I[Full tool access]
|
||||
H --> J[Build skill-forward prompt]
|
||||
I --> J
|
||||
J --> K[System: base context + skill instructions]
|
||||
K --> L[User: args as user message]
|
||||
L --> M[LLM call with tools]
|
||||
M --> N[Return result to caller]
|
||||
```
|
||||
|
||||
### Skill-Forward Prompt Construction
|
||||
|
||||
Reuses the pattern from [`agent_on_trigger()`](../src/agent.c:1837):
|
||||
|
||||
```
|
||||
[base system context / soul]
|
||||
|
||||
Skill execution context:
|
||||
- You are executing a specific skill on demand.
|
||||
- Follow the skill instructions below precisely.
|
||||
- The user's arguments provide the context for this execution.
|
||||
- Keep output concise and actionable.
|
||||
|
||||
Skill slug: deploy-website
|
||||
Skill address: 31123:<pubkey>:deploy-website
|
||||
Skill source: [own | external:<author_display_name>]
|
||||
|
||||
Skill instructions:
|
||||
[skill content here]
|
||||
```
|
||||
|
||||
User message:
|
||||
```
|
||||
[args provided by caller]
|
||||
```
|
||||
|
||||
The prompt is so skill-forward that the LLM has no real option but to execute the skill instructions against the provided args.
|
||||
|
||||
### Sandbox for External Skills
|
||||
|
||||
When executing a skill from another author (not own pubkey), a **tool sandbox** is applied by default:
|
||||
|
||||
**Allowed tools (safe/read-only):**
|
||||
- `nostr_query` — read Nostr events
|
||||
- `nostr_nip05_lookup` — NIP-05 lookups
|
||||
- `nostr_post` — publish events (the agent signs, so this is safe)
|
||||
- `nostr_list_manage` — manage lists
|
||||
- `skill_list`, `skill_search` — read skill metadata
|
||||
|
||||
**Blocked tools (destructive/dangerous):**
|
||||
- `shell_exec` — arbitrary command execution
|
||||
- `file_read`, `file_write` — filesystem access
|
||||
- Any future tools marked as `destructive: true`
|
||||
|
||||
**Override:** The admin can explicitly opt in to full tool access:
|
||||
- `/run --unsafe 31123:<pubkey>:risky-skill args`
|
||||
- `skill_run` with `sandbox: false`
|
||||
|
||||
### Skill Sharing on Nostr
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Friend creates skill] -->|kind 31123| B[Published on Nostr relays]
|
||||
B --> C{How do you find it?}
|
||||
C -->|skill_search popular:true| D[Discovery via WoT adoption lists]
|
||||
C -->|Friend tells you the slug| E[Direct reference]
|
||||
C -->|skill_search pubkey:friend| F[Browse friends skills]
|
||||
D --> G[skill_run - try it once, sandboxed]
|
||||
E --> G
|
||||
F --> G
|
||||
G -->|Liked it?| H{Adopt?}
|
||||
H -->|Yes| I[skill_adopt - permanent]
|
||||
H -->|No| J[Done - nothing persisted]
|
||||
I --> K[Shows in adopted skills context]
|
||||
K --> L[Agent uses it automatically]
|
||||
L -->|Or invoke directly| M[/run skill-slug args]
|
||||
```
|
||||
|
||||
## 3. Skill-Tool Maturity Levels
|
||||
|
||||
Skills can declare an execution maturity level that determines how they run:
|
||||
|
||||
| Level | Execution | LLM? | Use case |
|
||||
|-------|-----------|-------|----------|
|
||||
| `draft` | LLM interprets procedure text from skill instructions | Yes | Exploring and iterating on a workflow |
|
||||
| `guided` | LLM with forced tool_choice + parameter defaults | Yes, constrained | Workflow is stable but needs LLM judgment |
|
||||
| `hardened` | Deterministic step executor, no LLM | No | Workflow is proven and should run exactly as defined |
|
||||
|
||||
### Skill Definition Extensions
|
||||
|
||||
```yaml
|
||||
kind: 31123
|
||||
d: deploy_website
|
||||
execution: hardened
|
||||
tool_schema:
|
||||
name: deploy_website
|
||||
description: Build and deploy the static website
|
||||
parameters:
|
||||
target:
|
||||
type: string
|
||||
enum: [staging, production]
|
||||
default: staging
|
||||
steps:
|
||||
- tool: shell_exec
|
||||
args:
|
||||
command: "make build TARGET={{target}}"
|
||||
- tool: shell_exec
|
||||
args:
|
||||
command: "rsync -av dist/ server:/var/www/{{target}}/"
|
||||
- return: "Deployed to {{target}}"
|
||||
```
|
||||
|
||||
### How Each Level Works
|
||||
|
||||
**draft:** Current behavior. Skill instructions are injected into context. LLM reads them and decides which tools to call. No special handling needed.
|
||||
|
||||
**guided:** Agent sets `tool_choice` to the skill's preferred tool. Parameter defaults from the skill are merged with the model's generated arguments (skill defaults win on conflict). Reduces LLM freedom while still allowing it to fill in dynamic values.
|
||||
|
||||
**hardened:** Agent executes the `steps` array directly using the deterministic step executor. No LLM call at all. The skill becomes equivalent to a slash command.
|
||||
|
||||
### Promotion Workflow
|
||||
1. Admin iterates with LLM on a task (draft)
|
||||
2. Admin saves working procedure as a skill: `skill_create` with `execution: draft`
|
||||
3. Admin tests, refines, promotes: update skill to `execution: guided`
|
||||
4. Once proven reliable, promote to `execution: hardened` with explicit `steps`
|
||||
5. Hardened skills become available as slash commands: `/deploy_website {"target": "production"}`
|
||||
|
||||
## 4. Deterministic Step Executor
|
||||
|
||||
A simple sequential executor for hardened skills.
|
||||
|
||||
### Step Types
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
# Execute a tool
|
||||
- tool: shell_exec
|
||||
args: {command: "ls -la"}
|
||||
save_as: listing # optional: save result to variable
|
||||
|
||||
# Execute a tool with variable substitution
|
||||
- tool: nostr_post
|
||||
args:
|
||||
kind: 30023
|
||||
content: "{{file_content}}"
|
||||
tags: [["d", "{{slug}}"]]
|
||||
|
||||
# Conditional - simple
|
||||
- if: "{{listing.success}}"
|
||||
then:
|
||||
- tool: shell_exec
|
||||
args: {command: "echo done"}
|
||||
else:
|
||||
- return: "Failed: {{listing.error}}"
|
||||
|
||||
# Return final result
|
||||
- return: "Published to {{slug}}"
|
||||
```
|
||||
|
||||
### Variable Substitution
|
||||
- `{{param_name}}` — from tool parameters provided by caller
|
||||
- `{{step_name.field}}` — from a previous step's result (requires `save_as`)
|
||||
- Simple string replacement, no expression evaluation
|
||||
|
||||
### Implementation in C
|
||||
- Parse `steps` array from skill content (JSON or YAML)
|
||||
- Iterate steps sequentially
|
||||
- For each `tool` step: call [`tools_execute()`](../src/tools.c), optionally save result
|
||||
- For each `return` step: substitute variables and return string
|
||||
- For each `if` step: evaluate truthiness of variable, branch accordingly
|
||||
- Total implementation: ~200-400 lines of C
|
||||
|
||||
### Error Handling
|
||||
- If any tool step fails (returns `success: false`), abort and return the error
|
||||
- Optional `on_error` field per step for custom error messages
|
||||
- Timeout inherited from tool config
|
||||
|
||||
## 5. Tool Registration for Skill-Tools
|
||||
|
||||
Hardened and guided skills with a `tool_schema` field get registered in the tools array at runtime.
|
||||
|
||||
### At Skill Refresh Time
|
||||
1. Parse `tool_schema` from skill content
|
||||
2. Generate OpenAI function schema from it
|
||||
3. Append to the tools array returned by [`tools_build_openai_schema_json()`](../src/tools.c:919)
|
||||
4. When model calls the skill-tool, route to skill executor instead of hardcoded C function
|
||||
|
||||
### In [`tools_execute()`](../src/tools.c)
|
||||
1. Check if tool_name matches a hardcoded tool — execute normally
|
||||
2. If not, check if it matches a registered skill-tool
|
||||
3. If guided: run sub-LLM call with skill procedure + forced tool_choice
|
||||
4. If hardened: run deterministic step executor
|
||||
|
||||
## 6. Security Model
|
||||
|
||||
### Tool Classification
|
||||
|
||||
Tools are classified for sandbox purposes:
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
TOOL_SAFETY_SAFE, // read-only or agent-signed actions
|
||||
TOOL_SAFETY_DESTRUCTIVE // filesystem, shell, or external system mutations
|
||||
} tool_safety_t;
|
||||
```
|
||||
|
||||
| Tool | Safety | Reason |
|
||||
|------|--------|--------|
|
||||
| `nostr_query` | safe | Read-only |
|
||||
| `nostr_nip05_lookup` | safe | Read-only |
|
||||
| `nostr_post` | safe | Agent signs, admin controls keys |
|
||||
| `nostr_list_manage` | safe | Agent signs |
|
||||
| `skill_list` | safe | Read-only |
|
||||
| `skill_search` | safe | Read-only |
|
||||
| `skill_run` | safe | Recursive execution uses its own sandbox |
|
||||
| `shell_exec` | destructive | Arbitrary command execution |
|
||||
| `file_read` | destructive | Filesystem access |
|
||||
| `file_write` | destructive | Filesystem mutation |
|
||||
|
||||
### Sandbox Rules
|
||||
|
||||
| Scenario | Default sandbox | Override |
|
||||
|----------|----------------|---------|
|
||||
| Own adopted skill via `/run slug` | No sandbox | N/A |
|
||||
| External skill via `/run address` | Sandbox ON | `/run --unsafe address` |
|
||||
| `skill_run` tool, own skill | No sandbox | `sandbox: true` |
|
||||
| `skill_run` tool, external skill | Sandbox ON | `sandbox: false` |
|
||||
| Hardened skill steps | No sandbox (steps are explicit) | N/A |
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **Skill sharing:** Hardened skill-tools could be shared between agents via Nostr (kind 31123 events). Another agent adopts the skill and gets the tool automatically.
|
||||
- **Versioning:** Skills already use addressable events (d-tag). Updating a skill automatically updates the tool.
|
||||
- **Permissions:** Skill-tools could have their own permission model (e.g., some skill-tools available to WoT contacts).
|
||||
- **Composability:** Skill-tools calling other skill-tools (nested execution with sandbox inheritance).
|
||||
- **Dry-run mode:** A future `/run --dry` flag that shows what tools would be called without executing them.
|
||||
- **Skill ratings:** Agents could publish ratings/reviews of skills they've tried, building a WoT-based skill marketplace.
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
1. **Slash commands** (direct tool execution) — simplest, highest immediate value
|
||||
2. **`/run` for own adopted skills** — skill-forward execution of already-adopted skills
|
||||
3. **`skill_run` tool + external skill fetching** — enables "try my friend's skill" flow
|
||||
4. **External skill sandbox** — tool safety classification and sandbox enforcement
|
||||
5. **Hardened skill-tool step executor** — enables deterministic workflows
|
||||
6. **Skill-tool registration in tools array** — makes skill-tools visible to LLM
|
||||
7. **Guided execution with forced tool_choice** — bridges draft and hardened
|
||||
8. **Promotion workflow UX** — admin commands to change skill maturity level
|
||||
158
plans/unified_prompt_context.md
Normal file
158
plans/unified_prompt_context.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Plan: Unified Prompt Context for HTTP API and Nostr Paths
|
||||
|
||||
## Problem
|
||||
|
||||
The agent produces completely different LLM context depending on whether a message arrives via **Nostr DM** or the **HTTP API CLI chat app**.
|
||||
|
||||
### Nostr Path (working correctly)
|
||||
- `agent_on_message()` → `agent_build_admin_messages_json()` → tool loop
|
||||
- Builds **18 sections, ~8224 bytes** of context including:
|
||||
- System prompt / personality (from soul template)
|
||||
- Agent identity (pubkey)
|
||||
- Sender verification (admin tier)
|
||||
- Admin context (kind 0 profile, relay list, recent posts)
|
||||
- Startup events memory
|
||||
- Adopted skills
|
||||
- DM history (decrypted from Nostr relays)
|
||||
- Current user message
|
||||
|
||||
### HTTP API Path (broken)
|
||||
- CLI sends `{messages: [{role: "user", content: "Hello"}]}` to `/api/prompt/run`
|
||||
- `run_prompt_with_tools()` passes these raw messages directly to the LLM
|
||||
- Result: **1 section, ~35 bytes** — just the bare user message, zero agent context
|
||||
|
||||
## Solution: New `POST /api/prompt/agent` Endpoint
|
||||
|
||||
Add a new endpoint that mirrors the Nostr path's context assembly, so the CLI gets the same full agent context.
|
||||
|
||||
### Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Nostr DM arrives] --> B[agent_on_message]
|
||||
B --> C[agent_build_admin_messages_json]
|
||||
C --> D[Append user message]
|
||||
D --> E[Tool loop with llm_chat_with_tools_messages]
|
||||
E --> F[Send DM reply]
|
||||
|
||||
G[CLI sends POST /api/prompt/agent] --> H[handle_prompt_agent]
|
||||
H --> C
|
||||
C --> I[Append user message]
|
||||
I --> J[Tool loop - same as run_prompt_with_tools but with context]
|
||||
J --> K[Return JSON response]
|
||||
|
||||
style C fill:#4a9,stroke:#333,color:#fff
|
||||
```
|
||||
|
||||
Both paths share `agent_build_admin_messages_json()` as the single source of truth for context assembly.
|
||||
|
||||
### Request Format
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "What is the capital of France?",
|
||||
"model": "claude-haiku-4.5",
|
||||
"max_turns": 4
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `message` | string | yes | The user message to send to the agent |
|
||||
| `model` | string | no | Override the configured LLM model for this request |
|
||||
| `max_turns` | int | no | Max tool-use turns, default 4, max 16 |
|
||||
|
||||
### Response Format
|
||||
|
||||
Same as existing `/api/prompt/run`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"final_response": "The capital of France is Paris.",
|
||||
"turns": [...],
|
||||
"model_used": "claude-haiku-4.5",
|
||||
"total_input_tokens_estimate": 1973,
|
||||
"total_output_tokens_estimate": 12
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Add `handle_prompt_agent()` in `src/http_api.c`
|
||||
|
||||
New function that:
|
||||
1. Parses the JSON body to extract `message`, optional `model`, optional `max_turns`
|
||||
2. Applies model override if present via `maybe_model_override_begin()`
|
||||
3. Calls `agent_build_admin_messages_json(message, DIDACTYL_SENDER_ADMIN, &base_messages_json)` — same call the Nostr path uses
|
||||
4. Parses the result into a cJSON array
|
||||
5. Appends `{role: "user", content: message}` to the array — same as `agent_on_message()` does at line 1916
|
||||
6. Builds tool schema via `tools_build_openai_schema_json()`
|
||||
7. Runs the same tool loop as `run_prompt_with_tools()` but using the context-enriched messages
|
||||
8. Logs context via `agent_append_context_log("http_api_agent", "llm_chat_with_tools_messages", messages_json)`
|
||||
9. Returns the same response format as `/api/prompt/run`
|
||||
|
||||
Key reference points in existing code:
|
||||
- Context building: `agent_build_admin_messages_json()` at `src/agent.c:1737`
|
||||
- User message append: `append_simple_message()` pattern at `src/agent.c:1916`
|
||||
- Tool loop: reuse the loop logic from `run_prompt_with_tools()` at `src/http_api.c:278-345`
|
||||
- Context logging: `agent_append_context_log()` at `src/agent.c:1932`
|
||||
|
||||
### 2. Register the Route in `http_handler()`
|
||||
|
||||
Add before the existing `/api/prompt/run` route at `src/http_api.c:648`:
|
||||
|
||||
```c
|
||||
if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/prompt/agent"), NULL)) {
|
||||
handle_prompt_agent(c, hm);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Update `chat-didactyl-cli.js`
|
||||
|
||||
Change the CLI to call the new endpoint:
|
||||
|
||||
- Change `callDidactyl()` to POST to `/api/prompt/agent` instead of `/api/prompt/run`
|
||||
- Send `{message: "user text", max_turns: N}` instead of `{messages: [...], max_turns: N}`
|
||||
- The CLI no longer needs to maintain a `transcript` array for context — the server handles DM history from Nostr relays
|
||||
- Keep the transcript for local display purposes only
|
||||
|
||||
### 4. Context Logging Parity
|
||||
|
||||
Use a distinct but parallel phase label:
|
||||
- Nostr path: `llm_chat_with_tools_messages` (existing)
|
||||
- HTTP API agent path: `llm_chat_with_tools_messages_agent_api` (new)
|
||||
- HTTP API raw path: `llm_chat_with_tools_messages_http_api` (existing, unchanged)
|
||||
|
||||
This lets you distinguish the source in `context.log.md` while confirming the context structure is identical.
|
||||
|
||||
### 5. Update `docs/API.md`
|
||||
|
||||
Add documentation for the new `POST /api/prompt/agent` endpoint following the existing documentation style.
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/http_api.c` | Add `handle_prompt_agent()` function and route registration |
|
||||
| `chat-didactyl-cli.js` | Switch to `/api/prompt/agent`, simplify payload |
|
||||
| `docs/API.md` | Document new endpoint |
|
||||
|
||||
## What Stays the Same
|
||||
|
||||
- `/api/prompt/run` — unchanged, still accepts raw message arrays for custom/advanced use
|
||||
- `/api/prompt/run-simple` — unchanged
|
||||
- `/api/context/current` and `/api/context/parts` — unchanged
|
||||
- `agent_build_admin_messages_json()` — unchanged, already does exactly what we need
|
||||
- Nostr message handling — unchanged
|
||||
|
||||
## Remaining Consideration: Conversation History
|
||||
|
||||
The Nostr path gets DM history by querying encrypted kind-4 events from relays. The new `/api/prompt/agent` endpoint will include this same history since it calls `agent_build_admin_messages_json()`. This means:
|
||||
|
||||
- Messages sent via the CLI will NOT appear in the Nostr DM history (they are not published as Nostr events)
|
||||
- Messages sent via Nostr WILL appear in the context when using the CLI
|
||||
- This is acceptable — the CLI is a development/admin tool that piggybacks on the agent's full context
|
||||
|
||||
If in the future you want CLI messages to also appear in history, that would require either publishing them as Nostr DMs or maintaining a separate local history store — but that is out of scope for this change.
|
||||
1307
src/agent.c
1307
src/agent.c
File diff suppressed because it is too large
Load Diff
@@ -18,9 +18,12 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
const char* message,
|
||||
didactyl_sender_tier_t tier,
|
||||
void* user_data);
|
||||
int agent_build_admin_messages_json(const char* current_user_message, char** out_messages_json);
|
||||
int agent_build_admin_messages_json(const char* current_user_message,
|
||||
didactyl_sender_tier_t sender_tier,
|
||||
char** out_messages_json);
|
||||
tools_context_t* agent_tools_context(void);
|
||||
const char* agent_classify_message_part(cJSON* msg, int idx);
|
||||
void agent_append_context_log(const char* sender_pubkey_hex, const char* phase, const char* context_payload);
|
||||
void agent_cleanup(void);
|
||||
|
||||
#endif
|
||||
34
src/config.c
34
src/config.c
@@ -315,6 +315,33 @@ static int parse_triggers_config(cJSON* root, didactyl_config_t* config) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_dm_protocol_config(cJSON* root, didactyl_config_t* config) {
|
||||
if (!root || !config) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* dm_protocol = cJSON_GetObjectItemCaseSensitive(root, "dm_protocol");
|
||||
if (!dm_protocol) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!cJSON_IsString(dm_protocol) || !dm_protocol->valuestring) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (strcmp(dm_protocol->valuestring, "nip04") == 0) {
|
||||
config->dm_protocol = DM_PROTOCOL_NIP04;
|
||||
} else if (strcmp(dm_protocol->valuestring, "nip17") == 0) {
|
||||
config->dm_protocol = DM_PROTOCOL_NIP17;
|
||||
} else if (strcmp(dm_protocol->valuestring, "both") == 0) {
|
||||
config->dm_protocol = DM_PROTOCOL_BOTH;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_api_config(cJSON* root, didactyl_config_t* config) {
|
||||
cJSON* api = cJSON_GetObjectItemCaseSensitive(root, "api");
|
||||
if (!api || !cJSON_IsObject(api)) {
|
||||
@@ -637,6 +664,8 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
|
||||
memset(config, 0, sizeof(*config));
|
||||
snprintf(config->config_path, sizeof(config->config_path), "%s", path);
|
||||
config->dm_protocol = DM_PROTOCOL_NIP04;
|
||||
|
||||
config->tools.enabled = 1;
|
||||
config->tools.max_turns = 8;
|
||||
config->tools.shell.enabled = 1;
|
||||
@@ -754,6 +783,11 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
config->llm.max_tokens = (max_tokens && cJSON_IsNumber(max_tokens)) ? (int)max_tokens->valuedouble : 512;
|
||||
config->llm.temperature = (temperature && cJSON_IsNumber(temperature)) ? temperature->valuedouble : 0.7;
|
||||
|
||||
if (parse_dm_protocol_config(root, config) != 0) {
|
||||
config_set_error("invalid dm_protocol configuration (expected 'nip04', 'nip17', or 'both')");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (parse_tools_config(root, config) != 0) {
|
||||
config_set_error("invalid tools configuration");
|
||||
goto cleanup;
|
||||
|
||||
@@ -18,6 +18,12 @@ typedef struct {
|
||||
char public_key_hex[65];
|
||||
} agent_keys_t;
|
||||
|
||||
typedef enum {
|
||||
DM_PROTOCOL_NIP04 = 0,
|
||||
DM_PROTOCOL_NIP17 = 1,
|
||||
DM_PROTOCOL_BOTH = 2
|
||||
} dm_protocol_t;
|
||||
|
||||
typedef struct {
|
||||
char pubkey[65];
|
||||
} admin_config_t;
|
||||
@@ -89,6 +95,7 @@ typedef struct {
|
||||
typedef struct {
|
||||
agent_keys_t keys;
|
||||
admin_config_t admin;
|
||||
dm_protocol_t dm_protocol;
|
||||
char** relays;
|
||||
int relay_count;
|
||||
llm_config_t llm;
|
||||
|
||||
640
src/http_api.c
640
src/http_api.c
@@ -20,23 +20,22 @@ static http_api_context_t g_api_ctx;
|
||||
static int g_api_initialized = 0;
|
||||
static struct mg_mgr g_mgr;
|
||||
static struct mg_connection* g_listener = NULL;
|
||||
static int g_api_tls_enabled = 0;
|
||||
static struct mg_str g_tls_cert = {NULL, 0};
|
||||
static struct mg_str g_tls_key = {NULL, 0};
|
||||
|
||||
static const char* k_json_headers =
|
||||
"Content-Type: application/json\r\n"
|
||||
"Access-Control-Allow-Origin: *\r\n"
|
||||
"Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS\r\n"
|
||||
"Access-Control-Allow-Headers: Content-Type\r\n";
|
||||
"Access-Control-Allow-Headers: Content-Type\r\n"
|
||||
"Access-Control-Allow-Private-Network: true\r\n";
|
||||
|
||||
static int method_is(const struct mg_http_message* hm, const char* method) {
|
||||
size_t n = strlen(method);
|
||||
return hm && hm->method.len == n && strncmp(hm->method.buf, method, n) == 0;
|
||||
}
|
||||
|
||||
static int mgstr_eq(struct mg_str s, const char* lit) {
|
||||
size_t n = strlen(lit);
|
||||
return s.len == n && strncmp(s.buf, lit, n) == 0;
|
||||
}
|
||||
|
||||
static void reply_json(struct mg_connection* c, int status, cJSON* root) {
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
if (!out) {
|
||||
@@ -118,7 +117,7 @@ static int build_context_parts_response(cJSON* out_root) {
|
||||
if (!out_root) return -1;
|
||||
|
||||
char* messages_json = NULL;
|
||||
if (agent_build_admin_messages_json("", &messages_json) != 0 || !messages_json) {
|
||||
if (agent_build_admin_messages_json("", DIDACTYL_SENDER_ADMIN, &messages_json) != 0 || !messages_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -233,29 +232,443 @@ static int append_tool_result_message_local(cJSON* messages, const char* tool_ca
|
||||
return 0;
|
||||
}
|
||||
|
||||
static cJSON* run_prompt_with_tools(cJSON* body) {
|
||||
if (!body || !cJSON_IsObject(body)) return NULL;
|
||||
static int is_space_char_local(char c) {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r';
|
||||
}
|
||||
|
||||
cJSON* messages_node = cJSON_GetObjectItemCaseSensitive(body, "messages");
|
||||
if (!messages_node || !cJSON_IsArray(messages_node)) return NULL;
|
||||
static const char* skip_spaces_local(const char* s) {
|
||||
while (s && *s && is_space_char_local(*s)) s++;
|
||||
return s ? s : "";
|
||||
}
|
||||
|
||||
cJSON* convo = cJSON_Duplicate(messages_node, 1);
|
||||
if (!convo || !cJSON_IsArray(convo)) {
|
||||
cJSON_Delete(convo);
|
||||
static char* json_error_string_local(const char* msg) {
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
cJSON_AddBoolToObject(out, "success", 0);
|
||||
cJSON_AddStringToObject(out, "error", msg ? msg : "error");
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
static char* build_slash_args_json_local(const char* raw_args) {
|
||||
const char* s = skip_spaces_local(raw_args);
|
||||
if (!s || s[0] == '\0') {
|
||||
return strdup("{}");
|
||||
}
|
||||
|
||||
cJSON* parsed = cJSON_Parse(s);
|
||||
if (parsed) {
|
||||
char* out = cJSON_PrintUnformatted(parsed);
|
||||
cJSON_Delete(parsed);
|
||||
return out;
|
||||
}
|
||||
|
||||
cJSON* obj = cJSON_CreateObject();
|
||||
if (!obj) return NULL;
|
||||
cJSON_AddStringToObject(obj, "input", s);
|
||||
char* out = cJSON_PrintUnformatted(obj);
|
||||
cJSON_Delete(obj);
|
||||
return out;
|
||||
}
|
||||
|
||||
static int append_textf_http(char** buf, size_t* cap, size_t* used, const char* fmt, ...) {
|
||||
if (!buf || !cap || !used || !fmt) return -1;
|
||||
|
||||
while (1) {
|
||||
if (*used + 128U >= *cap) {
|
||||
size_t next_cap = (*cap) * 2U;
|
||||
char* grown = (char*)realloc(*buf, next_cap);
|
||||
if (!grown) return -1;
|
||||
*buf = grown;
|
||||
*cap = next_cap;
|
||||
}
|
||||
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
int n = vsnprintf(*buf + *used, *cap - *used, fmt, ap);
|
||||
va_end(ap);
|
||||
|
||||
if (n < 0) return -1;
|
||||
if ((size_t)n < (*cap - *used)) {
|
||||
*used += (size_t)n;
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t next_cap = (*cap) * 2U + (size_t)n + 64U;
|
||||
char* grown = (char*)realloc(*buf, next_cap);
|
||||
if (!grown) return -1;
|
||||
*buf = grown;
|
||||
*cap = next_cap;
|
||||
}
|
||||
}
|
||||
|
||||
static int append_markdown_indent_http(char** buf, size_t* cap, size_t* used, int depth) {
|
||||
for (int i = 0; i < depth; i++) {
|
||||
if (append_textf_http(buf, cap, used, " ") != 0) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int append_cjson_markdown_http(char** buf, size_t* cap, size_t* used, cJSON* node, int depth) {
|
||||
if (!node) {
|
||||
return append_markdown_indent_http(buf, cap, used, depth) == 0 &&
|
||||
append_textf_http(buf, cap, used, "- null\n") == 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
if (cJSON_IsObject(node)) {
|
||||
cJSON* child = NULL;
|
||||
cJSON_ArrayForEach(child, node) {
|
||||
const char* key = child->string ? child->string : "item";
|
||||
if (cJSON_IsString(child)) {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "- **%s:** %s\n", key,
|
||||
child->valuestring ? child->valuestring : "") != 0) {
|
||||
return -1;
|
||||
}
|
||||
} else if (cJSON_IsBool(child)) {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "- **%s:** %s\n", key,
|
||||
cJSON_IsTrue(child) ? "true" : "false") != 0) {
|
||||
return -1;
|
||||
}
|
||||
} else if (cJSON_IsNumber(child)) {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "- **%s:** %g\n", key, child->valuedouble) != 0) {
|
||||
return -1;
|
||||
}
|
||||
} else if (cJSON_IsNull(child)) {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "- **%s:** null\n", key) != 0) {
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "- **%s:**\n", key) != 0) {
|
||||
return -1;
|
||||
}
|
||||
if (append_cjson_markdown_http(buf, cap, used, child, depth + 1) != 0) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (cJSON_IsArray(node)) {
|
||||
int n = cJSON_GetArraySize(node);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* item = cJSON_GetArrayItem(node, i);
|
||||
if (cJSON_IsString(item)) {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "- %s\n", item->valuestring ? item->valuestring : "") != 0) {
|
||||
return -1;
|
||||
}
|
||||
} else if (cJSON_IsBool(item)) {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "- %s\n", cJSON_IsTrue(item) ? "true" : "false") != 0) {
|
||||
return -1;
|
||||
}
|
||||
} else if (cJSON_IsNumber(item)) {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "- %g\n", item->valuedouble) != 0) {
|
||||
return -1;
|
||||
}
|
||||
} else if (cJSON_IsNull(item)) {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "- null\n") != 0) {
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
if (append_markdown_indent_http(buf, cap, used, depth) != 0 ||
|
||||
append_textf_http(buf, cap, used, "-\n") != 0) {
|
||||
return -1;
|
||||
}
|
||||
if (append_cjson_markdown_http(buf, cap, used, item, depth + 1) != 0) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (cJSON_IsString(node)) {
|
||||
return append_markdown_indent_http(buf, cap, used, depth) == 0 &&
|
||||
append_textf_http(buf, cap, used, "- %s\n", node->valuestring ? node->valuestring : "") == 0 ? 0 : -1;
|
||||
}
|
||||
if (cJSON_IsBool(node)) {
|
||||
return append_markdown_indent_http(buf, cap, used, depth) == 0 &&
|
||||
append_textf_http(buf, cap, used, "- %s\n", cJSON_IsTrue(node) ? "true" : "false") == 0 ? 0 : -1;
|
||||
}
|
||||
if (cJSON_IsNumber(node)) {
|
||||
return append_markdown_indent_http(buf, cap, used, depth) == 0 &&
|
||||
append_textf_http(buf, cap, used, "- %g\n", node->valuedouble) == 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
return append_markdown_indent_http(buf, cap, used, depth) == 0 &&
|
||||
append_textf_http(buf, cap, used, "- null\n") == 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
static char* format_result_markdown_http(const char* raw_result) {
|
||||
if (!raw_result) {
|
||||
return strdup("No output.");
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_Parse(raw_result);
|
||||
if (!root) {
|
||||
return strdup(raw_result);
|
||||
}
|
||||
|
||||
size_t cap = strlen(raw_result) * 2U + 512U;
|
||||
if (cap < 2048U) cap = 2048U;
|
||||
size_t used = 0U;
|
||||
char* out = (char*)malloc(cap);
|
||||
if (!out) {
|
||||
cJSON_Delete(root);
|
||||
return strdup(raw_result);
|
||||
}
|
||||
out[0] = '\0';
|
||||
|
||||
if (append_cjson_markdown_http(&out, &cap, &used, root, 0) != 0) {
|
||||
cJSON_Delete(root);
|
||||
free(out);
|
||||
return strdup(raw_result);
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
static char* build_slash_help_all_json_local(void) {
|
||||
size_t cap = 2048U;
|
||||
size_t used = 0U;
|
||||
char* out = (char*)malloc(cap);
|
||||
if (!out) return NULL;
|
||||
out[0] = '\0';
|
||||
|
||||
if (append_textf_http(&out, &cap, &used, "AVAILABLE TOOLS\n") != 0) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int max_turns = 4;
|
||||
cJSON* max_turns_node = cJSON_GetObjectItemCaseSensitive(body, "max_turns");
|
||||
if (max_turns_node && cJSON_IsNumber(max_turns_node)) {
|
||||
max_turns = (int)max_turns_node->valuedouble;
|
||||
if (max_turns < 1) max_turns = 1;
|
||||
if (max_turns > 16) max_turns = 16;
|
||||
char* tool_list_json = tools_execute(g_api_ctx.tools_ctx, "tool_list", "{}");
|
||||
cJSON* tool_root = tool_list_json ? cJSON_Parse(tool_list_json) : NULL;
|
||||
cJSON* tools = tool_root ? cJSON_GetObjectItemCaseSensitive(tool_root, "tools") : NULL;
|
||||
|
||||
if (!tools || !cJSON_IsArray(tools) || cJSON_GetArraySize(tools) <= 0) {
|
||||
(void)append_textf_http(&out, &cap, &used, "- (none)\n");
|
||||
} else {
|
||||
int tn = cJSON_GetArraySize(tools);
|
||||
int* used_idx = (int*)calloc((size_t)tn, sizeof(int));
|
||||
if (!used_idx) {
|
||||
cJSON_Delete(tool_root);
|
||||
free(tool_list_json);
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
for (int k = 0; k < tn; k++) {
|
||||
int best = -1;
|
||||
const char* best_name = NULL;
|
||||
|
||||
for (int i = 0; i < tn; i++) {
|
||||
if (used_idx[i]) continue;
|
||||
cJSON* row = cJSON_GetArrayItem(tools, i);
|
||||
cJSON* name = row ? cJSON_GetObjectItemCaseSensitive(row, "name") : NULL;
|
||||
const char* name_s = (name && cJSON_IsString(name) && name->valuestring) ? name->valuestring : "unknown";
|
||||
if (best < 0 || strcmp(name_s, best_name) < 0) {
|
||||
best = i;
|
||||
best_name = name_s;
|
||||
}
|
||||
}
|
||||
|
||||
if (best < 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
used_idx[best] = 1;
|
||||
cJSON* row = cJSON_GetArrayItem(tools, best);
|
||||
cJSON* name = row ? cJSON_GetObjectItemCaseSensitive(row, "name") : NULL;
|
||||
cJSON* desc = row ? cJSON_GetObjectItemCaseSensitive(row, "description") : NULL;
|
||||
const char* name_s = (name && cJSON_IsString(name) && name->valuestring) ? name->valuestring : "unknown";
|
||||
const char* desc_s = (desc && cJSON_IsString(desc) && desc->valuestring) ? desc->valuestring : "";
|
||||
if (append_textf_http(&out, &cap, &used, "- %s%s%s\n", name_s, desc_s[0] ? " — " : "", desc_s) != 0) {
|
||||
free(used_idx);
|
||||
cJSON_Delete(tool_root);
|
||||
free(tool_list_json);
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
free(used_idx);
|
||||
}
|
||||
|
||||
cJSON_Delete(tool_root);
|
||||
free(tool_list_json);
|
||||
|
||||
if (append_textf_http(&out, &cap, &used, "\nAVAILABLE SKILLS\n") != 0) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char* skill_list_json = tools_execute(g_api_ctx.tools_ctx, "skill_list", "{}");
|
||||
cJSON* skill_root = skill_list_json ? cJSON_Parse(skill_list_json) : NULL;
|
||||
cJSON* skills = skill_root ? cJSON_GetObjectItemCaseSensitive(skill_root, "skills") : NULL;
|
||||
|
||||
if (!skills || !cJSON_IsArray(skills) || cJSON_GetArraySize(skills) <= 0) {
|
||||
(void)append_textf_http(&out, &cap, &used, "- (none)\n");
|
||||
} else {
|
||||
int sn = cJSON_GetArraySize(skills);
|
||||
for (int i = 0; i < sn; i++) {
|
||||
cJSON* row = cJSON_GetArrayItem(skills, i);
|
||||
cJSON* slug = row ? cJSON_GetObjectItemCaseSensitive(row, "slug") : NULL;
|
||||
cJSON* desc = row ? cJSON_GetObjectItemCaseSensitive(row, "description") : NULL;
|
||||
const char* slug_s = (slug && cJSON_IsString(slug) && slug->valuestring) ? slug->valuestring : NULL;
|
||||
const char* desc_s = (desc && cJSON_IsString(desc) && desc->valuestring) ? desc->valuestring : "";
|
||||
if (!slug_s || slug_s[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
if (append_textf_http(&out, &cap, &used, "- %s%s%s\n", slug_s, desc_s[0] ? " — " : "", desc_s) != 0) {
|
||||
cJSON_Delete(skill_root);
|
||||
free(skill_list_json);
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(skill_root);
|
||||
free(skill_list_json);
|
||||
return out;
|
||||
}
|
||||
|
||||
static char* build_slash_help_tool_json_local(const char* tool_name) {
|
||||
if (!tool_name || tool_name[0] == '\0') {
|
||||
return json_error_string_local("missing tool name");
|
||||
}
|
||||
|
||||
char* schema_json = tools_build_openai_schema_json(g_api_ctx.tools_ctx);
|
||||
if (!schema_json) return json_error_string_local("failed to build tool schema");
|
||||
|
||||
cJSON* schema = cJSON_Parse(schema_json);
|
||||
free(schema_json);
|
||||
if (!schema || !cJSON_IsArray(schema)) {
|
||||
cJSON_Delete(schema);
|
||||
return json_error_string_local("tool schema parse failure");
|
||||
}
|
||||
|
||||
cJSON* matched_fn = NULL;
|
||||
int n = cJSON_GetArraySize(schema);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* item = cJSON_GetArrayItem(schema, i);
|
||||
cJSON* fn = item ? cJSON_GetObjectItemCaseSensitive(item, "function") : NULL;
|
||||
cJSON* name = fn ? cJSON_GetObjectItemCaseSensitive(fn, "name") : NULL;
|
||||
if (name && cJSON_IsString(name) && name->valuestring && strcmp(name->valuestring, tool_name) == 0) {
|
||||
matched_fn = fn;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched_fn) {
|
||||
cJSON_Delete(schema);
|
||||
return json_error_string_local("unknown tool");
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
cJSON_Delete(schema);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "mode", "slash_help_tool");
|
||||
cJSON_AddStringToObject(out, "tool", tool_name);
|
||||
cJSON_AddItemToObject(out, "schema", cJSON_Duplicate(matched_fn, 1));
|
||||
|
||||
char* result = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
cJSON_Delete(schema);
|
||||
return result;
|
||||
}
|
||||
|
||||
static char* execute_slash_command_http(const char* message) {
|
||||
if (!message || message[0] != '/') return NULL;
|
||||
|
||||
const char* p = message + 1;
|
||||
while (*p && !is_space_char_local(*p)) p++;
|
||||
|
||||
size_t tool_len = (size_t)(p - (message + 1));
|
||||
if (tool_len == 0 || tool_len >= 128U) {
|
||||
return strdup("{\"success\":false,\"error\":\"invalid slash command\"}");
|
||||
}
|
||||
|
||||
char tool_name[128];
|
||||
memcpy(tool_name, message + 1, tool_len);
|
||||
tool_name[tool_len] = '\0';
|
||||
|
||||
const char* raw_args = skip_spaces_local(p);
|
||||
|
||||
char* result_json = NULL;
|
||||
if (strcmp(tool_name, "help") == 0) {
|
||||
if (!raw_args || raw_args[0] == '\0') {
|
||||
result_json = build_slash_help_all_json_local();
|
||||
} else {
|
||||
char help_tool[128];
|
||||
size_t i = 0;
|
||||
while (raw_args[i] && !is_space_char_local(raw_args[i]) && i < sizeof(help_tool) - 1U) {
|
||||
help_tool[i] = raw_args[i];
|
||||
i++;
|
||||
}
|
||||
help_tool[i] = '\0';
|
||||
result_json = build_slash_help_tool_json_local(help_tool);
|
||||
}
|
||||
} else {
|
||||
char* args_json = build_slash_args_json_local(raw_args);
|
||||
if (!args_json) {
|
||||
result_json = json_error_string_local("failed to parse slash args");
|
||||
} else {
|
||||
result_json = tools_execute(g_api_ctx.tools_ctx, tool_name, args_json);
|
||||
free(args_json);
|
||||
}
|
||||
}
|
||||
|
||||
if (!result_json) {
|
||||
result_json = strdup("{\"success\":false,\"error\":\"direct tool execution failed\"}");
|
||||
}
|
||||
|
||||
char* markdown_result = format_result_markdown_http(result_json);
|
||||
const char* dm_payload = markdown_result ? markdown_result : result_json;
|
||||
|
||||
size_t log_cap = strlen(message) + strlen(result_json ? result_json : "") + strlen(dm_payload ? dm_payload : "") + 192U;
|
||||
char* log_payload = (char*)malloc(log_cap);
|
||||
if (log_payload) {
|
||||
snprintf(log_payload, log_cap, "slash=%s\nresult_json=%s\nresult_markdown=%s",
|
||||
message,
|
||||
result_json ? result_json : "",
|
||||
dm_payload ? dm_payload : "");
|
||||
agent_append_context_log("http_api_agent", "direct_tool_exec", log_payload);
|
||||
free(log_payload);
|
||||
}
|
||||
|
||||
free(result_json);
|
||||
return markdown_result ? markdown_result : strdup("Command executed with no output.");
|
||||
}
|
||||
|
||||
static cJSON* run_prompt_with_tools_convo(cJSON* convo,
|
||||
int max_turns,
|
||||
const char* log_sender,
|
||||
const char* log_phase,
|
||||
const char* tool_limit_message) {
|
||||
if (!convo || !cJSON_IsArray(convo)) return NULL;
|
||||
|
||||
if (max_turns < 1) max_turns = 1;
|
||||
if (max_turns > 16) max_turns = 16;
|
||||
|
||||
char* tools_json = tools_build_openai_schema_json(g_api_ctx.tools_ctx);
|
||||
if (!tools_json) {
|
||||
cJSON_Delete(convo);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -265,7 +678,6 @@ static cJSON* run_prompt_with_tools(cJSON* body) {
|
||||
cJSON_Delete(root);
|
||||
cJSON_Delete(turns);
|
||||
free(tools_json);
|
||||
cJSON_Delete(convo);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -275,6 +687,10 @@ static cJSON* run_prompt_with_tools(cJSON* body) {
|
||||
char* messages_json = cJSON_PrintUnformatted(convo);
|
||||
if (!messages_json) break;
|
||||
|
||||
agent_append_context_log(log_sender ? log_sender : "http_api",
|
||||
log_phase ? log_phase : "llm_chat_with_tools_messages_http_api",
|
||||
messages_json);
|
||||
|
||||
llm_response_t resp;
|
||||
int rc = llm_chat_with_tools_messages(messages_json, tools_json, "auto", &resp);
|
||||
free(messages_json);
|
||||
@@ -340,7 +756,7 @@ static cJSON* run_prompt_with_tools(cJSON* body) {
|
||||
|
||||
done:
|
||||
if (!final_response) {
|
||||
final_response = strdup("I hit my tool-use limit for this prompt run.");
|
||||
final_response = strdup(tool_limit_message ? tool_limit_message : "I hit my tool-use limit for this prompt run.");
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
@@ -365,6 +781,32 @@ done:
|
||||
|
||||
free(final_response);
|
||||
free(tools_json);
|
||||
return root;
|
||||
}
|
||||
|
||||
static cJSON* run_prompt_with_tools(cJSON* body) {
|
||||
if (!body || !cJSON_IsObject(body)) return NULL;
|
||||
|
||||
cJSON* messages_node = cJSON_GetObjectItemCaseSensitive(body, "messages");
|
||||
if (!messages_node || !cJSON_IsArray(messages_node)) return NULL;
|
||||
|
||||
cJSON* convo = cJSON_Duplicate(messages_node, 1);
|
||||
if (!convo || !cJSON_IsArray(convo)) {
|
||||
cJSON_Delete(convo);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int max_turns = 4;
|
||||
cJSON* max_turns_node = cJSON_GetObjectItemCaseSensitive(body, "max_turns");
|
||||
if (max_turns_node && cJSON_IsNumber(max_turns_node)) {
|
||||
max_turns = (int)max_turns_node->valuedouble;
|
||||
}
|
||||
|
||||
cJSON* root = run_prompt_with_tools_convo(convo,
|
||||
max_turns,
|
||||
"http_api",
|
||||
"llm_chat_with_tools_messages_http_api",
|
||||
"I hit my tool-use limit for this prompt run.");
|
||||
cJSON_Delete(convo);
|
||||
return root;
|
||||
}
|
||||
@@ -396,7 +838,7 @@ static void handle_status(struct mg_connection* c) {
|
||||
|
||||
static void handle_context_current(struct mg_connection* c) {
|
||||
char* messages_json = NULL;
|
||||
if (agent_build_admin_messages_json("", &messages_json) != 0 || !messages_json) {
|
||||
if (agent_build_admin_messages_json("", DIDACTYL_SENDER_ADMIN, &messages_json) != 0 || !messages_json) {
|
||||
reply_error(c, 500, "failed to build context messages");
|
||||
return;
|
||||
}
|
||||
@@ -531,6 +973,115 @@ static void handle_prompt_run(struct mg_connection* c, const struct mg_http_mess
|
||||
cJSON_Delete(result);
|
||||
}
|
||||
|
||||
static void handle_prompt_agent(struct mg_connection* c, const struct mg_http_message* hm) {
|
||||
cJSON* body = parse_body_json(hm);
|
||||
if (!body || !cJSON_IsObject(body)) {
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 400, "invalid JSON body");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* message = cJSON_GetObjectItemCaseSensitive(body, "message");
|
||||
if (!message || !cJSON_IsString(message) || !message->valuestring) {
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 400, "message string is required");
|
||||
return;
|
||||
}
|
||||
|
||||
char* slash_result = execute_slash_command_http(message->valuestring);
|
||||
if (slash_result) {
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
cJSON* turns = cJSON_CreateArray();
|
||||
if (!out || !turns) {
|
||||
free(slash_result);
|
||||
cJSON_Delete(out);
|
||||
cJSON_Delete(turns);
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 500, "oom");
|
||||
return;
|
||||
}
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "final_response", slash_result);
|
||||
cJSON_AddItemToObject(out, "turns", turns);
|
||||
cJSON_AddNumberToObject(out, "total_input_tokens_estimate", estimate_tokens_from_chars((int)strlen(message->valuestring)));
|
||||
cJSON_AddNumberToObject(out, "total_output_tokens_estimate", estimate_tokens_from_chars((int)strlen(slash_result)));
|
||||
free(slash_result);
|
||||
cJSON_Delete(body);
|
||||
reply_json(c, 200, out);
|
||||
cJSON_Delete(out);
|
||||
return;
|
||||
}
|
||||
|
||||
int max_turns = 4;
|
||||
cJSON* max_turns_node = cJSON_GetObjectItemCaseSensitive(body, "max_turns");
|
||||
if (max_turns_node && cJSON_IsNumber(max_turns_node)) {
|
||||
max_turns = (int)max_turns_node->valuedouble;
|
||||
}
|
||||
|
||||
llm_config_t old_cfg;
|
||||
int overridden = 0;
|
||||
char* model_err = maybe_model_override_begin(body, &old_cfg, &overridden);
|
||||
if (model_err) {
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 400, model_err);
|
||||
return;
|
||||
}
|
||||
|
||||
char* base_messages_json = NULL;
|
||||
cJSON* convo = NULL;
|
||||
cJSON* result = NULL;
|
||||
|
||||
if (agent_build_admin_messages_json(message->valuestring,
|
||||
DIDACTYL_SENDER_ADMIN,
|
||||
&base_messages_json) != 0 || !base_messages_json) {
|
||||
maybe_model_override_end(&old_cfg, overridden);
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 500, "failed to build agent context");
|
||||
return;
|
||||
}
|
||||
|
||||
convo = cJSON_Parse(base_messages_json);
|
||||
free(base_messages_json);
|
||||
if (!convo || !cJSON_IsArray(convo)) {
|
||||
cJSON_Delete(convo);
|
||||
maybe_model_override_end(&old_cfg, overridden);
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 500, "failed to initialize conversation messages");
|
||||
return;
|
||||
}
|
||||
|
||||
if (append_simple_message_local(convo, "user", message->valuestring) != 0) {
|
||||
cJSON_Delete(convo);
|
||||
maybe_model_override_end(&old_cfg, overridden);
|
||||
cJSON_Delete(body);
|
||||
reply_error(c, 500, "failed to append user message");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* live_user_msg = cJSON_GetArrayItem(convo, cJSON_GetArraySize(convo) - 1);
|
||||
if (live_user_msg && cJSON_IsObject(live_user_msg)) {
|
||||
cJSON_AddNumberToObject(live_user_msg, "_ts", (double)time(NULL));
|
||||
}
|
||||
|
||||
result = run_prompt_with_tools_convo(convo,
|
||||
max_turns,
|
||||
"http_api_agent",
|
||||
"llm_chat_with_tools_messages_agent_api",
|
||||
"I hit my tool-use limit for this request.");
|
||||
cJSON_Delete(convo);
|
||||
|
||||
maybe_model_override_end(&old_cfg, overridden);
|
||||
cJSON_Delete(body);
|
||||
|
||||
if (!result) {
|
||||
reply_error(c, 500, "failed to run prompt");
|
||||
return;
|
||||
}
|
||||
|
||||
reply_json(c, 200, result);
|
||||
cJSON_Delete(result);
|
||||
}
|
||||
|
||||
static cJSON* run_variant(cJSON* variant) {
|
||||
if (!variant || !cJSON_IsObject(variant)) return NULL;
|
||||
cJSON* body_copy = cJSON_Duplicate(variant, 1);
|
||||
@@ -601,6 +1152,17 @@ static void handle_prompt_compare(struct mg_connection* c, const struct mg_http_
|
||||
}
|
||||
|
||||
static void http_handler(struct mg_connection* c, int ev, void* ev_data) {
|
||||
if (ev == MG_EV_ACCEPT) {
|
||||
if (g_api_tls_enabled) {
|
||||
struct mg_tls_opts opts;
|
||||
memset(&opts, 0, sizeof(opts));
|
||||
opts.cert = g_tls_cert;
|
||||
opts.key = g_tls_key;
|
||||
mg_tls_init(c, &opts);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev != MG_EV_HTTP_MSG) {
|
||||
return;
|
||||
}
|
||||
@@ -632,6 +1194,10 @@ static void http_handler(struct mg_connection* c, int ev, void* ev_data) {
|
||||
handle_prompt_run(c, hm);
|
||||
return;
|
||||
}
|
||||
if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/prompt/agent"), NULL)) {
|
||||
handle_prompt_agent(c, hm);
|
||||
return;
|
||||
}
|
||||
if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/prompt/compare"), NULL)) {
|
||||
handle_prompt_compare(c, hm);
|
||||
return;
|
||||
@@ -649,10 +1215,25 @@ int http_api_init(const http_api_context_t* ctx) {
|
||||
|
||||
mg_mgr_init(&g_mgr);
|
||||
|
||||
g_api_tls_enabled = 0;
|
||||
g_tls_cert = mg_file_read(&mg_fs_posix, "/home/teknari/.ssl_for_local_servers/cert.pem");
|
||||
g_tls_key = mg_file_read(&mg_fs_posix, "/home/teknari/.ssl_for_local_servers/key.pem");
|
||||
if (g_tls_cert.buf && g_tls_cert.len > 0 && g_tls_key.buf && g_tls_key.len > 0) {
|
||||
g_api_tls_enabled = 1;
|
||||
} else {
|
||||
if (g_tls_cert.buf) mg_free((void*)g_tls_cert.buf);
|
||||
if (g_tls_key.buf) mg_free((void*)g_tls_key.buf);
|
||||
g_tls_cert.buf = NULL;
|
||||
g_tls_cert.len = 0;
|
||||
g_tls_key.buf = NULL;
|
||||
g_tls_key.len = 0;
|
||||
}
|
||||
|
||||
char listen_url[320];
|
||||
snprintf(listen_url,
|
||||
sizeof(listen_url),
|
||||
"http://%s:%d",
|
||||
"%s://%s:%d",
|
||||
g_api_tls_enabled ? "https" : "http",
|
||||
g_api_ctx.cfg->api.bind_address[0] ? g_api_ctx.cfg->api.bind_address : "127.0.0.1",
|
||||
g_api_ctx.cfg->api.port > 0 ? g_api_ctx.cfg->api.port : 8484);
|
||||
|
||||
@@ -686,6 +1267,15 @@ void http_api_cleanup(void) {
|
||||
mg_mgr_free(&g_mgr);
|
||||
memset(&g_mgr, 0, sizeof(g_mgr));
|
||||
g_listener = NULL;
|
||||
|
||||
if (g_tls_cert.buf) mg_free((void*)g_tls_cert.buf);
|
||||
if (g_tls_key.buf) mg_free((void*)g_tls_key.buf);
|
||||
g_tls_cert.buf = NULL;
|
||||
g_tls_cert.len = 0;
|
||||
g_tls_key.buf = NULL;
|
||||
g_tls_key.len = 0;
|
||||
g_api_tls_enabled = 0;
|
||||
|
||||
memset(&g_api_ctx, 0, sizeof(g_api_ctx));
|
||||
g_api_initialized = 0;
|
||||
}
|
||||
19
src/llm.c
19
src/llm.c
@@ -64,6 +64,11 @@ static const char* detect_ca_bundle_path(void) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int url_looks_like_websocket(const char* url) {
|
||||
if (!url) return 0;
|
||||
return (strncmp(url, "ws://", 5) == 0) || (strncmp(url, "wss://", 6) == 0);
|
||||
}
|
||||
|
||||
static char* perform_http_request(const char* url, const char* body, int is_post) {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl || !url) {
|
||||
@@ -71,6 +76,16 @@ static char* perform_http_request(const char* url, const char* body, int is_post
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (url_looks_like_websocket(url)) {
|
||||
fprintf(stderr,
|
||||
"[didactyl] llm config error: base_url must be HTTP(S), got WebSocket URL: %s\n",
|
||||
url);
|
||||
fprintf(stderr,
|
||||
"[didactyl] llm hint: set llm.base_url to an OpenAI-compatible HTTPS endpoint, e.g. https://api.example.com/v1\n");
|
||||
curl_easy_cleanup(curl);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
|
||||
response_buffer_t rb = {0};
|
||||
struct curl_slist* headers = NULL;
|
||||
@@ -116,6 +131,10 @@ static char* perform_http_request(const char* url, const char* body, int is_post
|
||||
|
||||
if (status < 200 || status >= 300) {
|
||||
fprintf(stderr, "[didactyl] llm http request failed: status=%ld\n", status);
|
||||
if (status == 101) {
|
||||
fprintf(stderr,
|
||||
"[didactyl] llm hint: received HTTP 101 (Switching Protocols), which usually means llm.base_url points to a WebSocket server instead of an HTTP LLM API\n");
|
||||
}
|
||||
if (rb.data && rb.len > 0) {
|
||||
fprintf(stderr, "[didactyl] llm error response: %.1200s%s\n",
|
||||
rb.data,
|
||||
|
||||
@@ -252,7 +252,7 @@ int main(int argc, char** argv) {
|
||||
DIDACTYL_VERSION,
|
||||
connected_relays,
|
||||
cfg.relay_count);
|
||||
if (nostr_handler_send_dm(cfg.admin.pubkey, startup_dm) != 0) {
|
||||
if (nostr_handler_send_dm_auto(cfg.admin.pubkey, startup_dm) != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: failed to send startup status DM to admin");
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
// Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros
|
||||
#define DIDACTYL_VERSION_MAJOR 0
|
||||
#define DIDACTYL_VERSION_MINOR 0
|
||||
#define DIDACTYL_VERSION_PATCH 29
|
||||
#define DIDACTYL_VERSION "v0.0.29"
|
||||
#define DIDACTYL_VERSION_PATCH 30
|
||||
#define DIDACTYL_VERSION "v0.0.30"
|
||||
|
||||
// Agent metadata
|
||||
#define DIDACTYL_NAME "Didactyl"
|
||||
|
||||
@@ -14553,7 +14553,7 @@ static int mg_rsa_parse_der_int(const uint8_t **p, const uint8_t *end,
|
||||
// }
|
||||
static int mg_parse_ec_private_key(const uint8_t *der, size_t dersz,
|
||||
uint8_t *ec_key) {
|
||||
struct mg_der_tlv root, version, private_key_octets;
|
||||
struct mg_der_tlv root = {0}, version = {0}, private_key_octets = {0};
|
||||
|
||||
if (mg_der_parse((uint8_t *) der, dersz, &root) < 0 || root.type != 0x30) {
|
||||
MG_ERROR(("EC private key: invalid SEQUENCE"));
|
||||
@@ -14725,8 +14725,8 @@ static int mg_rsa_parse_key(const uint8_t *der, size_t dersz,
|
||||
// }
|
||||
static int mg_parse_pkcs8_key(const uint8_t *der, size_t dersz,
|
||||
struct tls_data *tls) {
|
||||
struct mg_der_tlv root, version, alg_id, private_key_octets;
|
||||
struct mg_der_tlv alg_oid, alg_params;
|
||||
struct mg_der_tlv root = {0}, version = {0}, alg_id = {0}, private_key_octets = {0};
|
||||
struct mg_der_tlv alg_oid = {0}, alg_params = {0};
|
||||
|
||||
if (mg_der_parse((uint8_t *) der, dersz, &root) < 0 || root.type != 0x30) {
|
||||
MG_ERROR(("PKCS#8: invalid PrivateKeyInfo SEQUENCE"));
|
||||
|
||||
@@ -51,6 +51,17 @@ static int g_seen_dm_next = 0;
|
||||
|
||||
static pthread_mutex_t g_dm_dedup_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
#define SENDER_PROTOCOL_CACHE_SIZE 128
|
||||
|
||||
typedef struct {
|
||||
char pubkey_hex[65];
|
||||
dm_protocol_t protocol;
|
||||
time_t seen_at;
|
||||
} sender_protocol_entry_t;
|
||||
|
||||
static sender_protocol_entry_t g_sender_protocol_cache[SENDER_PROTOCOL_CACHE_SIZE];
|
||||
static pthread_mutex_t g_sender_protocol_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static int dm_id_seen_or_remember(const char* event_id_hex) {
|
||||
if (!event_id_hex || strlen(event_id_hex) != 64U) {
|
||||
return 0;
|
||||
@@ -84,6 +95,88 @@ static int dm_id_seen_or_remember(const char* event_id_hex) {
|
||||
return seen;
|
||||
}
|
||||
|
||||
static didactyl_sender_tier_t sender_tier_from_pubkey(const char* sender_pubkey_hex) {
|
||||
if (!g_cfg || !sender_pubkey_hex) {
|
||||
return DIDACTYL_SENDER_STRANGER;
|
||||
}
|
||||
|
||||
if (strcmp(sender_pubkey_hex, g_cfg->admin.pubkey) == 0) {
|
||||
return DIDACTYL_SENDER_ADMIN;
|
||||
}
|
||||
|
||||
if (g_cfg->security.wot.enabled && nostr_handler_is_wot_contact(sender_pubkey_hex)) {
|
||||
return DIDACTYL_SENDER_WOT;
|
||||
}
|
||||
|
||||
return DIDACTYL_SENDER_STRANGER;
|
||||
}
|
||||
|
||||
static void sender_protocol_remember(const char* sender_pubkey_hex, dm_protocol_t protocol) {
|
||||
if (!sender_pubkey_hex || strlen(sender_pubkey_hex) != 64U) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (protocol != DM_PROTOCOL_NIP04 && protocol != DM_PROTOCOL_NIP17) {
|
||||
return;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_sender_protocol_mutex);
|
||||
|
||||
int slot = -1;
|
||||
time_t oldest_time = 0;
|
||||
int oldest_idx = 0;
|
||||
|
||||
for (int i = 0; i < SENDER_PROTOCOL_CACHE_SIZE; i++) {
|
||||
if (g_sender_protocol_cache[i].pubkey_hex[0] == '\0') {
|
||||
slot = i;
|
||||
break;
|
||||
}
|
||||
|
||||
if (strncmp(g_sender_protocol_cache[i].pubkey_hex, sender_pubkey_hex, 64U) == 0) {
|
||||
slot = i;
|
||||
break;
|
||||
}
|
||||
|
||||
if (i == 0 || g_sender_protocol_cache[i].seen_at < oldest_time) {
|
||||
oldest_time = g_sender_protocol_cache[i].seen_at;
|
||||
oldest_idx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (slot < 0) {
|
||||
slot = oldest_idx;
|
||||
}
|
||||
|
||||
memcpy(g_sender_protocol_cache[slot].pubkey_hex, sender_pubkey_hex, 64U);
|
||||
g_sender_protocol_cache[slot].pubkey_hex[64] = '\0';
|
||||
g_sender_protocol_cache[slot].protocol = protocol;
|
||||
g_sender_protocol_cache[slot].seen_at = time(NULL);
|
||||
|
||||
pthread_mutex_unlock(&g_sender_protocol_mutex);
|
||||
}
|
||||
|
||||
static dm_protocol_t sender_protocol_lookup(const char* sender_pubkey_hex) {
|
||||
if (!sender_pubkey_hex || strlen(sender_pubkey_hex) != 64U) {
|
||||
return DM_PROTOCOL_NIP04;
|
||||
}
|
||||
|
||||
dm_protocol_t out = DM_PROTOCOL_NIP04;
|
||||
|
||||
pthread_mutex_lock(&g_sender_protocol_mutex);
|
||||
for (int i = 0; i < SENDER_PROTOCOL_CACHE_SIZE; i++) {
|
||||
if (g_sender_protocol_cache[i].pubkey_hex[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
if (strncmp(g_sender_protocol_cache[i].pubkey_hex, sender_pubkey_hex, 64U) == 0) {
|
||||
out = g_sender_protocol_cache[i].protocol;
|
||||
break;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_sender_protocol_mutex);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
static const char* relay_status_str(nostr_pool_relay_status_t status) {
|
||||
switch (status) {
|
||||
case NOSTR_POOL_RELAY_DISCONNECTED:
|
||||
@@ -109,6 +202,7 @@ static int publish_kind_event_to_relays(int kind,
|
||||
nostr_publish_result_t* out_result);
|
||||
static void on_admin_context_event(cJSON* event, const char* relay_url, void* user_data);
|
||||
static int parse_kind3_wot_contacts(cJSON* tags);
|
||||
static int parse_kind10002_relays(cJSON* tags);
|
||||
static void upsert_kind1_note(time_t created_at, const char* content);
|
||||
static int startup_self_kind1_exists(void);
|
||||
static void load_startup_display_name(void);
|
||||
@@ -461,93 +555,162 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
const char* event_id_hex = (id && cJSON_IsString(id) && id->valuestring && strlen(id->valuestring) == 64U)
|
||||
? id->valuestring
|
||||
: NULL;
|
||||
int kind_val = (int)kind->valuedouble;
|
||||
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: kind=%d id=%.16s... from=%.16s... via %s",
|
||||
(int)kind->valuedouble,
|
||||
kind_val,
|
||||
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 sender_pubkey_hex[65] = {0};
|
||||
char* decrypted = NULL;
|
||||
const char* dedup_id_hex = event_id_hex;
|
||||
dm_protocol_t received_protocol = DM_PROTOCOL_NIP04;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
didactyl_sender_tier_t tier = DIDACTYL_SENDER_STRANGER;
|
||||
if (strcmp(pubkey->valuestring, g_cfg->admin.pubkey) == 0) {
|
||||
tier = DIDACTYL_SENDER_ADMIN;
|
||||
} else if (g_cfg->security.wot.enabled && nostr_handler_is_wot_contact(pubkey->valuestring)) {
|
||||
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",
|
||||
pubkey->valuestring,
|
||||
relay_url ? relay_url : "unknown relay");
|
||||
if (kind_val == 4) {
|
||||
if (g_cfg->dm_protocol == DM_PROTOCOL_NIP17) {
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: ignoring kind4 in dm_protocol=nip17 mode");
|
||||
return;
|
||||
}
|
||||
|
||||
if (g_cfg->security.stranger_response[0] != '\0') {
|
||||
(void)nostr_handler_send_dm(pubkey->valuestring, g_cfg->security.stranger_response);
|
||||
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;
|
||||
}
|
||||
|
||||
memcpy(sender_pubkey_hex, pubkey->valuestring, 65U);
|
||||
|
||||
unsigned char sender_pubkey[32];
|
||||
if (hex_to_pubkey(sender_pubkey_hex, sender_pubkey) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
decrypted = (char*)malloc(NOSTR_NIP04_MAX_PLAINTEXT_SIZE);
|
||||
if (!decrypted) {
|
||||
fprintf(stderr, "[didactyl] failed to allocate DM decrypt buffer\n");
|
||||
return;
|
||||
}
|
||||
decrypted[0] = '\0';
|
||||
|
||||
trace_event_json("received encrypted DM event:", event);
|
||||
|
||||
if (nostr_nip04_decrypt(g_cfg->keys.private_key, sender_pubkey, content->valuestring, decrypted, NOSTR_NIP04_MAX_PLAINTEXT_SIZE) != NOSTR_SUCCESS) {
|
||||
fprintf(stdout, "[didactyl] failed to decrypt incoming DM from %.16s...\n", sender_pubkey_hex);
|
||||
free(decrypted);
|
||||
return;
|
||||
}
|
||||
|
||||
trace_plaintext_dm("received decrypted DM content:", decrypted);
|
||||
received_protocol = DM_PROTOCOL_NIP04;
|
||||
} else if (kind_val == 1059) {
|
||||
if (g_cfg->dm_protocol == DM_PROTOCOL_NIP04) {
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: ignoring kind1059 in dm_protocol=nip04 mode");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* rumor = nostr_nip17_receive_dm(event, g_cfg->keys.private_key);
|
||||
if (!rumor) {
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: failed to unwrap/decrypt NIP-17 gift wrap %.16s...",
|
||||
event_id_hex ? event_id_hex : "<no-id>");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* rumor_id = cJSON_GetObjectItemCaseSensitive(rumor, "id");
|
||||
cJSON* rumor_kind = cJSON_GetObjectItemCaseSensitive(rumor, "kind");
|
||||
cJSON* rumor_pubkey = cJSON_GetObjectItemCaseSensitive(rumor, "pubkey");
|
||||
cJSON* rumor_content = cJSON_GetObjectItemCaseSensitive(rumor, "content");
|
||||
|
||||
if (!rumor_kind || !rumor_pubkey || !rumor_content ||
|
||||
!cJSON_IsNumber(rumor_kind) || !cJSON_IsString(rumor_pubkey) || !cJSON_IsString(rumor_content) ||
|
||||
!rumor_pubkey->valuestring || strlen(rumor_pubkey->valuestring) != 64U) {
|
||||
cJSON_Delete(rumor);
|
||||
return;
|
||||
}
|
||||
|
||||
int rumor_kind_val = (int)rumor_kind->valuedouble;
|
||||
if (rumor_kind_val != 14 && rumor_kind_val != 15 && rumor_kind_val != 7) {
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: ignoring NIP-17 rumor kind=%d", rumor_kind_val);
|
||||
cJSON_Delete(rumor);
|
||||
return;
|
||||
}
|
||||
|
||||
memcpy(sender_pubkey_hex, rumor_pubkey->valuestring, 65U);
|
||||
|
||||
const char* rumor_id_hex = (rumor_id && cJSON_IsString(rumor_id) && rumor_id->valuestring && strlen(rumor_id->valuestring) == 64U)
|
||||
? rumor_id->valuestring
|
||||
: NULL;
|
||||
if (rumor_id_hex) {
|
||||
dedup_id_hex = rumor_id_hex;
|
||||
}
|
||||
|
||||
decrypted = strdup(rumor_content->valuestring ? rumor_content->valuestring : "");
|
||||
cJSON_Delete(rumor);
|
||||
if (!decrypted) {
|
||||
return;
|
||||
}
|
||||
|
||||
trace_plaintext_dm("received NIP-17 DM content:", decrypted);
|
||||
received_protocol = DM_PROTOCOL_NIP17;
|
||||
} else {
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: ignoring unsupported kind=%d", kind_val);
|
||||
return;
|
||||
}
|
||||
|
||||
unsigned char sender_pubkey[32];
|
||||
if (hex_to_pubkey(pubkey->valuestring, sender_pubkey) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
char* decrypted = (char*)malloc(NOSTR_NIP04_MAX_PLAINTEXT_SIZE);
|
||||
if (!decrypted) {
|
||||
fprintf(stderr, "[didactyl] failed to allocate DM decrypt buffer\n");
|
||||
return;
|
||||
}
|
||||
decrypted[0] = '\0';
|
||||
|
||||
trace_event_json("received encrypted DM event:", event);
|
||||
|
||||
if (nostr_nip04_decrypt(g_cfg->keys.private_key, sender_pubkey, content->valuestring, decrypted, NOSTR_NIP04_MAX_PLAINTEXT_SIZE) != NOSTR_SUCCESS) {
|
||||
fprintf(stdout, "[didactyl] failed to decrypt incoming DM from %.16s...\n", pubkey->valuestring);
|
||||
if (!sender_pubkey_hex[0] || strlen(sender_pubkey_hex) != 64U) {
|
||||
free(decrypted);
|
||||
return;
|
||||
}
|
||||
|
||||
trace_plaintext_dm("received decrypted DM content:", decrypted);
|
||||
didactyl_sender_tier_t tier = sender_tier_from_pubkey(sender_pubkey_hex);
|
||||
|
||||
if (event_id_hex && dm_id_seen_or_remember(event_id_hex)) {
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: sender=%.16s... tier=%d (admin=%.16s...)",
|
||||
sender_pubkey_hex, (int)tier, g_cfg->admin.pubkey);
|
||||
|
||||
sender_protocol_remember(sender_pubkey_hex, received_protocol);
|
||||
|
||||
if (tier == DIDACTYL_SENDER_STRANGER) {
|
||||
if (!g_cfg->security.stranger.enabled) {
|
||||
DEBUG_LOG("[didactyl] ignored DM from stranger %.16s... via %s",
|
||||
sender_pubkey_hex,
|
||||
relay_url ? relay_url : "unknown relay");
|
||||
free(decrypted);
|
||||
return;
|
||||
}
|
||||
|
||||
if (g_cfg->security.stranger_response[0] != '\0') {
|
||||
(void)nostr_handler_send_dm_auto(sender_pubkey_hex, g_cfg->security.stranger_response);
|
||||
}
|
||||
free(decrypted);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dedup_id_hex && dm_id_seen_or_remember(dedup_id_hex)) {
|
||||
DEBUG_LOG("[didactyl] skipped duplicate DM event %.16s... from %.16s... via %s",
|
||||
event_id_hex,
|
||||
pubkey->valuestring,
|
||||
dedup_id_hex,
|
||||
sender_pubkey_hex,
|
||||
relay_url ? relay_url : "unknown relay");
|
||||
free(decrypted);
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] received kind %d event %.16s... from %.16s... via %s tier=%d",
|
||||
(int)kind->valuedouble,
|
||||
event_id_hex ? event_id_hex : "<no-id>",
|
||||
pubkey->valuestring,
|
||||
DEBUG_INFO("[didactyl] received kind %d event %.16s... from %.16s... via %s tier=%d protocol=%s",
|
||||
kind_val,
|
||||
dedup_id_hex ? dedup_id_hex : "<no-id>",
|
||||
sender_pubkey_hex,
|
||||
relay_url ? relay_url : "unknown relay",
|
||||
(int)tier);
|
||||
g_dm_callback(pubkey->valuestring, decrypted, tier, g_dm_user_data);
|
||||
(int)tier,
|
||||
received_protocol == DM_PROTOCOL_NIP17 ? "nip17" : "nip04");
|
||||
|
||||
g_dm_callback(sender_pubkey_hex, decrypted, tier, g_dm_user_data);
|
||||
free(decrypted);
|
||||
}
|
||||
|
||||
@@ -631,6 +794,49 @@ static int parse_kind3_wot_contacts(cJSON* tags) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_kind10002_relays(cJSON* tags) {
|
||||
if (!tags || !cJSON_IsArray(tags)) {
|
||||
free(g_admin_kind10002_json);
|
||||
g_admin_kind10002_json = strdup("[]");
|
||||
return g_admin_kind10002_json ? 0 : -1;
|
||||
}
|
||||
|
||||
cJSON* relays = cJSON_CreateArray();
|
||||
if (!relays) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int n = cJSON_GetArraySize(tags);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(tags, i);
|
||||
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* key = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* val = cJSON_GetArrayItem(tag, 1);
|
||||
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) || !key->valuestring || !val->valuestring) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strcmp(key->valuestring, "r") != 0 || val->valuestring[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(relays, cJSON_CreateString(val->valuestring));
|
||||
}
|
||||
|
||||
char* relays_json = cJSON_PrintUnformatted(relays);
|
||||
cJSON_Delete(relays);
|
||||
if (!relays_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
free(g_admin_kind10002_json);
|
||||
g_admin_kind10002_json = relays_json;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void upsert_kind1_note(time_t created_at, const char* content) {
|
||||
if (!content) {
|
||||
return;
|
||||
@@ -702,9 +908,8 @@ static void on_admin_context_event(cJSON* event, const char* relay_url, void* us
|
||||
g_admin_kind0_json = strdup(content->valuestring);
|
||||
} else if (k == 3 && g_cfg->admin_context.track_kind_3 && tags && cJSON_IsArray(tags)) {
|
||||
(void)parse_kind3_wot_contacts(tags);
|
||||
} else if (k == 10002 && g_cfg->admin_context.track_kind_10002 && content && cJSON_IsString(content) && content->valuestring) {
|
||||
free(g_admin_kind10002_json);
|
||||
g_admin_kind10002_json = strdup(content->valuestring);
|
||||
} else if (k == 10002 && g_cfg->admin_context.track_kind_10002 && tags && cJSON_IsArray(tags)) {
|
||||
(void)parse_kind10002_relays(tags);
|
||||
} else if (k == 1 && g_cfg->admin_context.track_kind_1 && content && cJSON_IsString(content) && content->valuestring) {
|
||||
time_t ts = (created_at && cJSON_IsNumber(created_at)) ? (time_t)created_at->valuedouble : time(NULL);
|
||||
upsert_kind1_note(ts, content->valuestring);
|
||||
@@ -882,7 +1087,12 @@ int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(4));
|
||||
if (g_cfg->dm_protocol == DM_PROTOCOL_NIP04 || g_cfg->dm_protocol == DM_PROTOCOL_BOTH) {
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(4));
|
||||
}
|
||||
if (g_cfg->dm_protocol == DM_PROTOCOL_NIP17 || g_cfg->dm_protocol == DM_PROTOCOL_BOTH) {
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(1059));
|
||||
}
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToArray(p_values, cJSON_CreateString(g_cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(filter, "#p", p_values);
|
||||
@@ -1064,6 +1274,36 @@ int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message)
|
||||
return sent > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
int nostr_handler_send_dm_auto(const char* recipient_pubkey_hex, const char* message) {
|
||||
if (!recipient_pubkey_hex || !message) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!g_cfg) {
|
||||
return nostr_handler_send_dm(recipient_pubkey_hex, message);
|
||||
}
|
||||
|
||||
if (g_cfg->dm_protocol == DM_PROTOCOL_NIP04) {
|
||||
return nostr_handler_send_dm(recipient_pubkey_hex, message);
|
||||
}
|
||||
|
||||
if (g_cfg->dm_protocol == DM_PROTOCOL_NIP17) {
|
||||
return nostr_handler_send_dm_nip17(recipient_pubkey_hex, message, NULL);
|
||||
}
|
||||
|
||||
dm_protocol_t target = sender_protocol_lookup(recipient_pubkey_hex);
|
||||
if (target == DM_PROTOCOL_NIP17) {
|
||||
int rc17 = nostr_handler_send_dm_nip17(recipient_pubkey_hex, message, NULL);
|
||||
if (rc17 == 0) {
|
||||
return 0;
|
||||
}
|
||||
DEBUG_WARN("[didactyl] auto DM fallback to NIP-04 for %.16s... after NIP-17 send failure",
|
||||
recipient_pubkey_hex);
|
||||
}
|
||||
|
||||
return nostr_handler_send_dm(recipient_pubkey_hex, message);
|
||||
}
|
||||
|
||||
static int publish_kind_event_to_relays(int kind,
|
||||
const char* content,
|
||||
cJSON* tags,
|
||||
@@ -1789,6 +2029,10 @@ void nostr_handler_cleanup(void) {
|
||||
g_seen_dm_count = 0;
|
||||
g_seen_dm_next = 0;
|
||||
|
||||
pthread_mutex_lock(&g_sender_protocol_mutex);
|
||||
memset(g_sender_protocol_cache, 0, sizeof(g_sender_protocol_cache));
|
||||
pthread_mutex_unlock(&g_sender_protocol_mutex);
|
||||
|
||||
pthread_mutex_lock(&g_admin_ctx_mutex);
|
||||
free_admin_context_locked();
|
||||
pthread_mutex_unlock(&g_admin_ctx_mutex);
|
||||
|
||||
@@ -31,6 +31,7 @@ int nostr_handler_init(didactyl_config_t* config);
|
||||
int nostr_handler_subscribe_admin_context(void);
|
||||
int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data);
|
||||
int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message);
|
||||
int nostr_handler_send_dm_auto(const char* recipient_pubkey_hex, const char* message);
|
||||
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags, nostr_publish_result_t* out_result);
|
||||
void nostr_handler_publish_result_free(nostr_publish_result_t* result);
|
||||
char* nostr_handler_query_json(cJSON* filter, int timeout_ms);
|
||||
|
||||
@@ -179,6 +179,7 @@ static void init_section_defaults(prompt_template_section_t* sec) {
|
||||
snprintf(sec->role, sizeof(sec->role), "system");
|
||||
sec->content_template = NULL;
|
||||
sec->limit = 0;
|
||||
sec->skip_if_empty = 0;
|
||||
sec->provider_name = NULL;
|
||||
sec->provider_content_template = NULL;
|
||||
}
|
||||
@@ -281,6 +282,16 @@ int prompt_template_parse(const char* soul_content, prompt_template_t* out_templ
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts_with(line, "skip_if_empty:")) {
|
||||
char* flag = line + strlen("skip_if_empty:");
|
||||
flag = ltrim_inplace(flag);
|
||||
rtrim_inplace(flag);
|
||||
out_template->sections[current].skip_if_empty =
|
||||
(strcmp(flag, "true") == 0 || strcmp(flag, "1") == 0) ? 1 : 0;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts_with(line, "content:")) {
|
||||
char* val = line + strlen("content:");
|
||||
val = ltrim_inplace(val);
|
||||
@@ -307,6 +318,7 @@ int prompt_template_parse(const char* soul_content, prompt_template_t* out_templ
|
||||
if (starts_with(next_ltrim, "- section:") ||
|
||||
starts_with(next_ltrim, "role:") ||
|
||||
starts_with(next_ltrim, "limit:") ||
|
||||
starts_with(next_ltrim, "skip_if_empty:") ||
|
||||
starts_with(next_ltrim, "content:") ||
|
||||
starts_with(next_ltrim, "provider:")) {
|
||||
break;
|
||||
@@ -378,6 +390,7 @@ int prompt_template_parse(const char* soul_content, prompt_template_t* out_templ
|
||||
if (starts_with(next, "- section:") ||
|
||||
starts_with(next, "role:") ||
|
||||
starts_with(next, "limit:") ||
|
||||
starts_with(next, "skip_if_empty:") ||
|
||||
starts_with(next, "content:") ||
|
||||
starts_with(next, "provider:")) {
|
||||
break;
|
||||
@@ -486,6 +499,14 @@ cJSON* prompt_template_build_messages(const prompt_template_t* tmpl,
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (sec->skip_if_empty) {
|
||||
char* chk = ltrim_inplace(resolved);
|
||||
if (chk && *chk == '\0') {
|
||||
free(resolved);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (append_message_object(out, role, resolved) != 0) {
|
||||
free(resolved);
|
||||
cJSON_Delete(out);
|
||||
@@ -515,6 +536,7 @@ void prompt_template_free(prompt_template_t* tmpl) {
|
||||
tmpl->sections[i].name[0] = '\0';
|
||||
tmpl->sections[i].role[0] = '\0';
|
||||
tmpl->sections[i].limit = 0;
|
||||
tmpl->sections[i].skip_if_empty = 0;
|
||||
}
|
||||
|
||||
tmpl->section_count = 0;
|
||||
|
||||
@@ -13,6 +13,7 @@ typedef struct {
|
||||
char role[PROMPT_TEMPLATE_MAX_ROLE_LEN];
|
||||
char* content_template;
|
||||
int limit;
|
||||
int skip_if_empty;
|
||||
char* provider_name;
|
||||
char* provider_content_template;
|
||||
} prompt_template_section_t;
|
||||
|
||||
442
src/tools.c
442
src/tools.c
@@ -596,6 +596,120 @@ static cJSON* parse_tool_args_json(const char* args_json) {
|
||||
return args;
|
||||
}
|
||||
|
||||
static cJSON* tasks_create_empty_root(void) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON* tasks = cJSON_CreateArray();
|
||||
if (!root || !tasks) {
|
||||
cJSON_Delete(root);
|
||||
cJSON_Delete(tasks);
|
||||
return NULL;
|
||||
}
|
||||
cJSON_AddItemToObject(root, "tasks", tasks);
|
||||
cJSON_AddNumberToObject(root, "next_id", 1);
|
||||
return root;
|
||||
}
|
||||
|
||||
static const char* normalize_task_status(const char* status) {
|
||||
if (!status) return NULL;
|
||||
if (strcmp(status, "pending") == 0) return "pending";
|
||||
if (strcmp(status, "active") == 0) return "active";
|
||||
if (strcmp(status, "done") == 0) return "done";
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static cJSON* tasks_load_root(const char* path) {
|
||||
if (!path) return NULL;
|
||||
|
||||
FILE* fp = fopen(path, "rb");
|
||||
if (!fp) {
|
||||
if (access(path, F_OK) == 0) {
|
||||
return NULL;
|
||||
}
|
||||
return tasks_create_empty_root();
|
||||
}
|
||||
|
||||
if (fseek(fp, 0, SEEK_END) != 0) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
}
|
||||
long len = ftell(fp);
|
||||
if (len < 0) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
}
|
||||
if (fseek(fp, 0, SEEK_SET) != 0) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char* buf = (char*)malloc((size_t)len + 1U);
|
||||
if (!buf) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
size_t n = fread(buf, 1, (size_t)len, fp);
|
||||
fclose(fp);
|
||||
if (n != (size_t)len) {
|
||||
free(buf);
|
||||
return NULL;
|
||||
}
|
||||
buf[len] = '\0';
|
||||
|
||||
cJSON* root = cJSON_Parse(buf);
|
||||
free(buf);
|
||||
if (!root || !cJSON_IsObject(root)) {
|
||||
cJSON_Delete(root);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON* tasks = cJSON_GetObjectItemCaseSensitive(root, "tasks");
|
||||
if (!tasks || !cJSON_IsArray(tasks)) {
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(root, "tasks");
|
||||
cJSON_AddItemToObject(root, "tasks", cJSON_CreateArray());
|
||||
}
|
||||
|
||||
cJSON* next_id = cJSON_GetObjectItemCaseSensitive(root, "next_id");
|
||||
if (!next_id || !cJSON_IsNumber(next_id) || next_id->valuedouble < 1) {
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(root, "next_id");
|
||||
cJSON_AddNumberToObject(root, "next_id", 1);
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
static int tasks_save_root(const char* path, cJSON* root) {
|
||||
if (!path || !root) return -1;
|
||||
char* raw = cJSON_PrintUnformatted(root);
|
||||
if (!raw) return -1;
|
||||
|
||||
FILE* fp = fopen(path, "wb");
|
||||
if (!fp) {
|
||||
free(raw);
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t len = strlen(raw);
|
||||
size_t n = fwrite(raw, 1, len, fp);
|
||||
fclose(fp);
|
||||
free(raw);
|
||||
return (n == len) ? 0 : -1;
|
||||
}
|
||||
|
||||
static cJSON* task_find_by_id(cJSON* tasks, int id, int* out_index) {
|
||||
if (!tasks || !cJSON_IsArray(tasks) || id <= 0) return NULL;
|
||||
int n = cJSON_GetArraySize(tasks);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* task = cJSON_GetArrayItem(tasks, i);
|
||||
cJSON* tid = task ? cJSON_GetObjectItemCaseSensitive(task, "id") : NULL;
|
||||
if (tid && cJSON_IsNumber(tid) && (int)tid->valuedouble == id) {
|
||||
if (out_index) *out_index = i;
|
||||
return task;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
char* data;
|
||||
size_t len;
|
||||
@@ -1841,6 +1955,61 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
|
||||
cJSON_AddItemToObject(t36, "function", t36_fn);
|
||||
cJSON_AddItemToArray(tools, t36);
|
||||
|
||||
cJSON* t37 = cJSON_CreateObject();
|
||||
cJSON* t37_fn = cJSON_CreateObject();
|
||||
cJSON* t37_params = cJSON_CreateObject();
|
||||
cJSON* t37_props = cJSON_CreateObject();
|
||||
cJSON* t37_required = cJSON_CreateArray();
|
||||
|
||||
cJSON_AddStringToObject(t37, "type", "function");
|
||||
cJSON_AddStringToObject(t37_fn, "name", "task_manage");
|
||||
cJSON_AddStringToObject(t37_fn, "description", "Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)");
|
||||
cJSON_AddStringToObject(t37_params, "type", "object");
|
||||
cJSON_AddItemToObject(t37_params, "properties", t37_props);
|
||||
cJSON_AddItemToObject(t37_params, "required", t37_required);
|
||||
|
||||
cJSON* p_task_action = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_task_action, "type", "string");
|
||||
cJSON* p_task_action_enum = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(p_task_action_enum, cJSON_CreateString("list"));
|
||||
cJSON_AddItemToArray(p_task_action_enum, cJSON_CreateString("add"));
|
||||
cJSON_AddItemToArray(p_task_action_enum, cJSON_CreateString("update"));
|
||||
cJSON_AddItemToArray(p_task_action_enum, cJSON_CreateString("remove"));
|
||||
cJSON_AddItemToArray(p_task_action_enum, cJSON_CreateString("clear"));
|
||||
cJSON_AddItemToArray(p_task_action_enum, cJSON_CreateString("replace"));
|
||||
cJSON_AddItemToObject(p_task_action, "enum", p_task_action_enum);
|
||||
cJSON_AddItemToObject(t37_props, "action", p_task_action);
|
||||
|
||||
cJSON* p_task_text = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_task_text, "type", "string");
|
||||
cJSON_AddItemToObject(t37_props, "text", p_task_text);
|
||||
|
||||
cJSON* p_task_id = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_task_id, "type", "integer");
|
||||
cJSON_AddItemToObject(t37_props, "id", p_task_id);
|
||||
|
||||
cJSON* p_task_status = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_task_status, "type", "string");
|
||||
cJSON* p_task_status_enum = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(p_task_status_enum, cJSON_CreateString("pending"));
|
||||
cJSON_AddItemToArray(p_task_status_enum, cJSON_CreateString("active"));
|
||||
cJSON_AddItemToArray(p_task_status_enum, cJSON_CreateString("done"));
|
||||
cJSON_AddItemToObject(p_task_status, "enum", p_task_status_enum);
|
||||
cJSON_AddItemToObject(t37_props, "status", p_task_status);
|
||||
|
||||
cJSON* p_task_tasks = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_task_tasks, "type", "array");
|
||||
cJSON* p_task_tasks_item = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_task_tasks_item, "type", "string");
|
||||
cJSON_AddItemToObject(p_task_tasks, "items", p_task_tasks_item);
|
||||
cJSON_AddItemToObject(t37_props, "tasks", p_task_tasks);
|
||||
|
||||
cJSON_AddItemToArray(t37_required, cJSON_CreateString("action"));
|
||||
|
||||
cJSON_AddItemToObject(t37_fn, "parameters", t37_params);
|
||||
cJSON_AddItemToObject(t37, "function", t37_fn);
|
||||
cJSON_AddItemToArray(tools, t37);
|
||||
|
||||
char* out = cJSON_PrintUnformatted(tools);
|
||||
cJSON_Delete(tools);
|
||||
return out;
|
||||
@@ -4368,6 +4537,276 @@ static char* execute_file_write(tools_context_t* ctx, const char* args_json) {
|
||||
return json;
|
||||
}
|
||||
|
||||
static char* execute_task_manage(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_tool_args_json(args_json);
|
||||
if (!args) return json_error("invalid arguments JSON");
|
||||
|
||||
cJSON* action = cJSON_GetObjectItemCaseSensitive(args, "action");
|
||||
if (!action || !cJSON_IsString(action) || !action->valuestring || action->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
return json_error("task_manage requires string action");
|
||||
}
|
||||
|
||||
char tasks_path[PATH_MAX];
|
||||
if (build_tool_path(ctx, "tasks.json", tasks_path, sizeof(tasks_path)) != 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("failed to resolve tasks file path");
|
||||
}
|
||||
|
||||
cJSON* root = tasks_load_root(tasks_path);
|
||||
if (!root) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("failed to load tasks file");
|
||||
}
|
||||
|
||||
cJSON* tasks = cJSON_GetObjectItemCaseSensitive(root, "tasks");
|
||||
if (!tasks || !cJSON_IsArray(tasks)) {
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(root, "tasks");
|
||||
tasks = cJSON_CreateArray();
|
||||
cJSON_AddItemToObject(root, "tasks", tasks);
|
||||
}
|
||||
|
||||
cJSON* next_id_item = cJSON_GetObjectItemCaseSensitive(root, "next_id");
|
||||
int next_id = (next_id_item && cJSON_IsNumber(next_id_item) && next_id_item->valuedouble >= 1)
|
||||
? (int)next_id_item->valuedouble
|
||||
: 1;
|
||||
|
||||
const char* action_s = action->valuestring;
|
||||
int mutated = 0;
|
||||
|
||||
if (strcmp(action_s, "list") == 0) {
|
||||
/* no-op */
|
||||
} else if (strcmp(action_s, "add") == 0) {
|
||||
cJSON* text = cJSON_GetObjectItemCaseSensitive(args, "text");
|
||||
cJSON* status = cJSON_GetObjectItemCaseSensitive(args, "status");
|
||||
if (!text || !cJSON_IsString(text) || !text->valuestring || text->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error("task_manage add requires non-empty string text");
|
||||
}
|
||||
|
||||
const char* normalized_status = "pending";
|
||||
if (status && !cJSON_IsNull(status)) {
|
||||
if (!cJSON_IsString(status) || !status->valuestring) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error("task_manage add status must be string when provided");
|
||||
}
|
||||
normalized_status = normalize_task_status(status->valuestring);
|
||||
if (!normalized_status) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error("task_manage add status must be pending, active, or done");
|
||||
}
|
||||
}
|
||||
|
||||
cJSON* task = cJSON_CreateObject();
|
||||
if (!task) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error("allocation failure");
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
cJSON_AddNumberToObject(task, "id", next_id++);
|
||||
cJSON_AddStringToObject(task, "text", text->valuestring);
|
||||
cJSON_AddStringToObject(task, "status", normalized_status);
|
||||
cJSON_AddNumberToObject(task, "created_at", (double)now);
|
||||
cJSON_AddNumberToObject(task, "updated_at", (double)now);
|
||||
cJSON_AddItemToArray(tasks, task);
|
||||
mutated = 1;
|
||||
} else if (strcmp(action_s, "update") == 0) {
|
||||
cJSON* id = cJSON_GetObjectItemCaseSensitive(args, "id");
|
||||
cJSON* text = cJSON_GetObjectItemCaseSensitive(args, "text");
|
||||
cJSON* status = cJSON_GetObjectItemCaseSensitive(args, "status");
|
||||
|
||||
if (!id || !cJSON_IsNumber(id) || id->valuedouble < 1) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error("task_manage update requires integer id");
|
||||
}
|
||||
|
||||
if ((!text || cJSON_IsNull(text)) && (!status || cJSON_IsNull(status))) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error("task_manage update requires text and/or status");
|
||||
}
|
||||
|
||||
cJSON* task = task_find_by_id(tasks, (int)id->valuedouble, NULL);
|
||||
if (!task) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error("task not found");
|
||||
}
|
||||
|
||||
if (text && !cJSON_IsNull(text)) {
|
||||
if (!cJSON_IsString(text) || !text->valuestring || text->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error("task_manage update text must be non-empty string when provided");
|
||||
}
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(task, "text");
|
||||
cJSON_AddStringToObject(task, "text", text->valuestring);
|
||||
mutated = 1;
|
||||
}
|
||||
|
||||
if (status && !cJSON_IsNull(status)) {
|
||||
if (!cJSON_IsString(status) || !status->valuestring) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error("task_manage update status must be string when provided");
|
||||
}
|
||||
const char* normalized_status = normalize_task_status(status->valuestring);
|
||||
if (!normalized_status) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error("task_manage update status must be pending, active, or done");
|
||||
}
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(task, "status");
|
||||
cJSON_AddStringToObject(task, "status", normalized_status);
|
||||
mutated = 1;
|
||||
}
|
||||
|
||||
if (mutated) {
|
||||
time_t now = time(NULL);
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(task, "updated_at");
|
||||
cJSON_AddNumberToObject(task, "updated_at", (double)now);
|
||||
}
|
||||
} else if (strcmp(action_s, "remove") == 0) {
|
||||
cJSON* id = cJSON_GetObjectItemCaseSensitive(args, "id");
|
||||
if (!id || !cJSON_IsNumber(id) || id->valuedouble < 1) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error("task_manage remove requires integer id");
|
||||
}
|
||||
|
||||
int idx = -1;
|
||||
cJSON* task = task_find_by_id(tasks, (int)id->valuedouble, &idx);
|
||||
if (!task || idx < 0) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error("task not found");
|
||||
}
|
||||
|
||||
cJSON_DeleteItemFromArray(tasks, idx);
|
||||
mutated = 1;
|
||||
} else if (strcmp(action_s, "clear") == 0) {
|
||||
cJSON* status = cJSON_GetObjectItemCaseSensitive(args, "status");
|
||||
if (!status || cJSON_IsNull(status)) {
|
||||
while (cJSON_GetArraySize(tasks) > 0) {
|
||||
cJSON_DeleteItemFromArray(tasks, 0);
|
||||
}
|
||||
mutated = 1;
|
||||
} else {
|
||||
if (!cJSON_IsString(status) || !status->valuestring) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error("task_manage clear status must be string when provided");
|
||||
}
|
||||
const char* normalized_status = normalize_task_status(status->valuestring);
|
||||
if (!normalized_status) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error("task_manage clear status must be pending, active, or done");
|
||||
}
|
||||
|
||||
int i = 0;
|
||||
while (i < cJSON_GetArraySize(tasks)) {
|
||||
cJSON* task = cJSON_GetArrayItem(tasks, i);
|
||||
cJSON* task_status = task ? cJSON_GetObjectItemCaseSensitive(task, "status") : NULL;
|
||||
const char* task_status_s = (task_status && cJSON_IsString(task_status) && task_status->valuestring)
|
||||
? task_status->valuestring
|
||||
: "pending";
|
||||
if (strcmp(task_status_s, normalized_status) == 0) {
|
||||
cJSON_DeleteItemFromArray(tasks, i);
|
||||
mutated = 1;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (strcmp(action_s, "replace") == 0) {
|
||||
cJSON* tasks_in = cJSON_GetObjectItemCaseSensitive(args, "tasks");
|
||||
if (!tasks_in || !cJSON_IsArray(tasks_in)) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error("task_manage replace requires array tasks");
|
||||
}
|
||||
|
||||
while (cJSON_GetArraySize(tasks) > 0) {
|
||||
cJSON_DeleteItemFromArray(tasks, 0);
|
||||
}
|
||||
|
||||
int n = cJSON_GetArraySize(tasks_in);
|
||||
time_t now = time(NULL);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* text = cJSON_GetArrayItem(tasks_in, i);
|
||||
if (!text || !cJSON_IsString(text) || !text->valuestring || text->valuestring[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* task = cJSON_CreateObject();
|
||||
if (!task) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error("allocation failure");
|
||||
}
|
||||
|
||||
cJSON_AddNumberToObject(task, "id", next_id++);
|
||||
cJSON_AddStringToObject(task, "text", text->valuestring);
|
||||
cJSON_AddStringToObject(task, "status", "pending");
|
||||
cJSON_AddNumberToObject(task, "created_at", (double)now);
|
||||
cJSON_AddNumberToObject(task, "updated_at", (double)now);
|
||||
cJSON_AddItemToArray(tasks, task);
|
||||
}
|
||||
mutated = 1;
|
||||
} else {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error("task_manage action must be one of: list, add, update, remove, clear, replace");
|
||||
}
|
||||
|
||||
if (mutated) {
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(root, "next_id");
|
||||
cJSON_AddNumberToObject(root, "next_id", next_id);
|
||||
if (tasks_save_root(tasks_path, root) != 0) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error("failed to save tasks file");
|
||||
}
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "action", action_s);
|
||||
cJSON_AddStringToObject(out, "path", tasks_path);
|
||||
cJSON_AddBoolToObject(out, "mutated", mutated ? 1 : 0);
|
||||
cJSON_AddNumberToObject(out, "count", cJSON_GetArraySize(tasks));
|
||||
|
||||
cJSON* tasks_dup = cJSON_Duplicate(tasks, 1);
|
||||
if (!tasks_dup) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
cJSON_Delete(out);
|
||||
return NULL;
|
||||
}
|
||||
cJSON_AddItemToObject(out, "tasks", tasks_dup);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json;
|
||||
}
|
||||
|
||||
static char* execute_tool_list(tools_context_t* ctx, const char* args_json) {
|
||||
(void)args_json;
|
||||
if (!ctx) {
|
||||
@@ -4815,6 +5254,9 @@ char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* arg
|
||||
if (strcmp(tool_name, "nostr_file_md_to_longform_post") == 0) {
|
||||
return execute_nostr_file_md_to_longform_post(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "task_manage") == 0) {
|
||||
return execute_task_manage(ctx, args_json);
|
||||
}
|
||||
|
||||
return json_error("unknown tool");
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ static void execute_template_action(trigger_manager_t* mgr,
|
||||
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);
|
||||
(void)nostr_handler_send_dm_auto(mgr->cfg->admin.pubkey, body);
|
||||
} else if (strncmp(rendered, "POST:", 5) == 0) {
|
||||
const char* body = rendered + 5;
|
||||
while (*body == ' ') body++;
|
||||
@@ -170,7 +170,7 @@ static void execute_template_action(trigger_manager_t* mgr,
|
||||
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);
|
||||
(void)nostr_handler_send_dm_auto(mgr->cfg->admin.pubkey, rendered);
|
||||
}
|
||||
|
||||
free(rendered);
|
||||
|
||||
1
tasks.json
Normal file
1
tasks.json
Normal file
@@ -0,0 +1 @@
|
||||
{"tasks":[{"id":5,"text":"Tweet 3: Gets Smarter Over Time - \"Didactyl starts as an AI that explores and learns. Over time, the best workflows get locked in as reliable, fast processes. It evolves from experimental to hardened—from thinking to doing. An agent that improves itself. #nostr #agents\"","status":"pending","created_at":1772625247,"updated_at":1772625247}],"next_id":6}
|
||||
Reference in New Issue
Block a user