Compare commits

...

4 Commits

23 changed files with 3291 additions and 623 deletions

1528
.skills-edit-check.mjs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -54,11 +54,11 @@ Skills compose by adoption-list order (`10123`) and trigger tags carry runtime e
Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers.
## Current Status — v0.2.24
## Current Status — v0.2.28
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.2.24Improve skills editor trigger filters with per-trigger helper widgets and presets
> Last release update: v0.2.28Updated LT and Anvil genesis configs: unified DM history context as startup skill, migrated default skills into startup_events, rotated admin npub to npub1rmz9..., and redeployed/restarted didactyl+simon+anvil
- Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected

View File

@@ -20,8 +20,8 @@ Minimum practical sections:
- `key.nsec` (or runtime `--nsec` / `DIDACTYL_NSEC`)
- `admin.pubkey`
- `llm`
- `startup_events` (must include kind `10002` relay tags)
- `encrypted_events` with a `kind:30078` / `d_tag:user-settings` payload containing `global_llm` and `didactyl`
Typical optional sections:
@@ -96,17 +96,39 @@ Security behavior:
## Encrypted Config Events
Didactyl exposes config persistence tools for encrypted self-config on Nostr:
Didactyl uses encrypted runtime config in two ways:
- `config_store` — publish encrypted kind `30078` config by `d_tag`
- `config_recall` — query+decrypt kind `30078` config by `d_tag`
1. At bootstrap time via `genesis.jsonc` `encrypted_events` entries
2. At runtime via encrypted kind `30078` self-events (`config_store` / `config_recall`)
Recommended tags:
`encrypted_events` format:
- `d=llm_config`
- `d=agent_config`
- `kind` (currently `30078`)
- `d_tag` (required: `user-settings`)
- `content` (JSON string payload to encrypt and publish)
These are encrypted to self with NIP-44.
`d=user-settings` payload shape:
```json
{
"v": 2,
"updatedAt": 0,
"global_llm": {
"provider": "openai",
"api_key": "sk-...",
"model": "gpt-4o-mini",
"base_url": "https://api.openai.com/v1",
"max_tokens": 512,
"temperature": 0.7
},
"didactyl": {
"admin_pubkey": "npub1...",
"dm_protocol": "nip04"
}
}
```
These values are encrypted to self with NIP-44 before publish.
---
@@ -116,6 +138,8 @@ These are encrypted to self with NIP-44.
That relay list is used as the initial network attachment for querying existing state and publishing startup events.
`encrypted_events` are a separate section and are not part of relay-list derivation.
---
## Migration Notes (v0.2.0)

View File

@@ -18,16 +18,15 @@
// Supported values: "nip04", "nip17", or "both".
"dm_protocol": "nip04",
// ─── LLM Provider ──────────────────────────────────────────────────
// OpenAI-compatible endpoint settings.
"llm": {
"provider": "openai",
"api_key": "sk-REPLACE_WITH_API_KEY",
"model": "gpt-4o-mini",
"base_url": "https://api.openai.com/v1",
"max_tokens": 512,
"temperature": 0.7
},
// ─── Encrypted Startup Config Events ───────────────────────────────
// These are published as NIP-44 encrypted kind 30078 self-events on first run.
"encrypted_events": [
{
"kind": 30078,
"d_tag": "user-settings",
"content": "{\"v\":2,\"updatedAt\":0,\"global_llm\":{\"provider\":\"openai\",\"api_key\":\"sk-REPLACE_WITH_API_KEY\",\"model\":\"gpt-4o-mini\",\"base_url\":\"https://api.openai.com/v1\",\"max_tokens\":512,\"temperature\":0.7},\"didactyl\":{\"admin_pubkey\":\"npub1REPLACE_WITH_ADMIN_PUBKEY\",\"dm_protocol\":\"nip04\",\"max_turns\":40}}"
}
],
// ─── HTTP Admin API ────────────────────────────────────────────────
"api": {
@@ -60,7 +59,6 @@
["r", "wss://relay.damus.io"],
["r", "wss://relay.primal.net"]
]
}
},
{
"kind": 31124,
@@ -76,12 +74,12 @@
},
{
"kind": 31124,
"content": "## Recent Conversation\n\n{{nostr_dm_history({\"format\":\"text\",\"limit\":12})}}",
"content": "## DM History Context\n\n### Instructions\n- Reference prior conversation naturally when it's relevant to the current request.\n- Do not repeat entire DM history back to the user unless explicitly asked.\n- Use this context to avoid asking questions that were already answered in recent messages.\n\n### Recent DM History (last 10 messages)\n\n{{nostr_dm_history({\"limit\":10,\"format\":\"text\"})}}",
"tags": [
["d", "dm_history"],
["d", "dm_history_context"],
["app", "didactyl"],
["scope", "private"],
["description", "DM conversation history for context continuity"],
["description", "DM history context"],
["trigger", "dm"],
["filter", "{\"from\":\"admin\"}"]
]

80
genesis.lt.didactyl.jsonc Normal file
View File

@@ -0,0 +1,80 @@
{
// ─── Agent Identity Keys ───────────────────────────────────────────
// Keep nsec private; npub/hex fields can be used for verification/debugging.
"key": {
"nsec": "nsec17pcmmqxh99rtu5ctpxrdyywdd8f7zfqgf3y2f4fvcm3tk3vpuyqqvlxl4e"
},
// ─── Administrator ─────────────────────────────────────────────────
// Admin pubkey (npub or hex) controls privileged interactions.
"admin": {
"pubkey": "npub1rmz9gu6de0m0u4ysrn39crrud099ahvfgs6pvasl4hpjr5ud7yus54xv06"
},
// ─── DM Protocol ──────────────────────────────────────────────────
// Supported values: "nip04", "nip17", or "both".
"dm_protocol": "nip04",
// ─── HTTP Admin API ───────────────────────────────────────────────
// Local API for model/context inspection and runtime controls.
"api": {
"enabled": true,
"port": 8484,
"bind_address": "127.0.0.1"
},
// ─── Encrypted Startup Config Events ──────────────────────────────
// These are published as NIP-44 encrypted kind 30078 self-events on first run.
"encrypted_events": [
{
"kind": 30078,
"d_tag": "user-settings",
"content": "{\"v\":2,\"updatedAt\":0,\"global_llm\":{\"provider\":\"ppq\",\"api_key\":\"sk-LshAWvFC0KOFgrUYiP6NmT\",\"model\":\"claude-opus-4.6\",\"base_url\":\"https://api.ppq.ai\",\"max_tokens\":200000,\"temperature\":0.7},\"didactyl\":{\"admin_pubkey\":\"npub1rmz9gu6de0m0u4ysrn39crrud099ahvfgs6pvasl4hpjr5ud7yus54xv06\",\"dm_protocol\":\"nip04\",\"max_turns\":40}}"
}
],
// ─── Startup Events ───────────────────────────────────────────────
// Minimal relay list required for relay pool initialization.
"startup_events": [
{
"kind": 10002,
"content": "",
"tags": [
["r", "wss://relay.damus.io"],
["r", "wss://relay.primal.net"],
["r", "wss://relay.laantungir.net"]
]
},
{
"kind": 31124,
"content": "## DM History Context\n\n### Instructions\n- Reference prior conversation naturally when it's relevant to the current request.\n- Do not repeat entire DM history back to the user unless explicitly asked.\n- Use this context to avoid asking questions that were already answered in recent messages.\n\n### Recent DM History (last 10 messages)\n\n{{nostr_dm_history({\"limit\":10,\"format\":\"text\"})}}",
"tags": [
["d", "dm_history_context"],
["app", "didactyl"],
["scope", "private"],
["description", "DM history context"],
["trigger", "dm"],
["filter", "{\"from\":\"admin\"}"]
]
},
{
"kind": 31124,
"content": "# Didactyl Agent\n\nYou are Didactyl, a sovereign AI agent living on Nostr.\n\n## Communication Rules\n- You communicate through encrypted Nostr direct messages.\n- Keep responses concise and clear.\n\n## Behavior\n- Be helpful and technically accurate.\n- If unsure, state uncertainty directly.\n- Prefer actionable, practical advice.\n- Use the person's name when messaging them if you know it.\n- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n## Tool Use Policy\n- You have tools available and should use them when a request requires taking action.\n- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.\n- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n- After a tool call, base your answer on the actual tool result.\n- Never claim a tool was run if no tool was executed.\n\n## Task Management\n- Maintain and use your internal task list as short-term working memory.\n- Break long or complex actions into clear tasks before executing them.\n- Update task status as you complete steps so your plan stays accurate.\n\n## Safety\n- Do not claim to have executed actions you did not execute.\n- You may share your public key (npub) with anyone.\n- Never reveal your private key (nsec) under any circumstance.\n\n---template---\n\n- section: admin_identity\n role: system\n tool: admin_identity\n skip_if_empty: true\n\n- section: admin_profile\n role: system\n tool: nostr_admin_profile\n skip_if_empty: true\n\n- section: admin_contacts\n role: system\n tool: nostr_admin_contacts\n skip_if_empty: true\n\n- section: admin_relays\n role: system\n tool: nostr_admin_relays\n skip_if_empty: true\n\n- section: admin_notes\n role: system\n tool: nostr_admin_notes\n skip_if_empty: true\n\n- section: agent_identity\n role: system\n tool: agent_identity\n skip_if_empty: true\n\n- section: agent_profile\n role: system\n tool: nostr_agent_profile\n skip_if_empty: true\n\n- section: agent_contacts\n role: system\n tool: nostr_agent_contacts\n skip_if_empty: true\n\n- section: agent_relays\n role: system\n tool: nostr_agent_relays\n skip_if_empty: true\n\n- section: agent_notes\n role: system\n tool: nostr_agent_notes\n skip_if_empty: true\n\n- section: tasks\n role: system\n tool: task_list\n skip_if_empty: true\n\n- section: dm_history\n role: expand\n limit: 12\n\n- section: conversation\n role: user\n tool: message_current\n skip_if_empty: true",
"tags": [
[
"d",
"didactyl-default"
],
[
"app",
"didactyl"
],
[
"scope",
"private"
]
]
}
]
}

101
genesis.lt.simon.jsonc Normal file
View File

@@ -0,0 +1,101 @@
{
// ─── Agent Identity Keys ───────────────────────────────────────────
// Keep nsec private; npub/hex fields can be used for verification/debugging.
"key": {
"nsec": "nsec17yzm0lyr479z73sgnpf9yuxv0nwclpaff0k45npsh49nddpc3fhqmhh2ph"
},
// ─── Administrator ─────────────────────────────────────────────────
// Admin pubkey (npub or hex) controls privileged interactions.
"admin": {
"pubkey": "npub1rmz9gu6de0m0u4ysrn39crrud099ahvfgs6pvasl4hpjr5ud7yus54xv06"
},
// ─── DM Protocol ──────────────────────────────────────────────────
// Supported values: "nip04", "nip17", or "both".
"dm_protocol": "nip04",
// ─── HTTP Admin API ───────────────────────────────────────────────
// Local API for model/context inspection and runtime controls.
"api": {
"enabled": true,
"port": 8485,
"bind_address": "127.0.0.1"
},
// ─── Encrypted Startup Config Events ──────────────────────────────
// These are published as NIP-44 encrypted kind 30078 self-events on first run.
"encrypted_events": [
{
"kind": 30078,
"d_tag": "user-settings",
"content": "{\"v\":2,\"updatedAt\":0,\"global_llm\":{\"provider\":\"ppq\",\"api_key\":\"sk-L9kd0LkZaoeaua0qqosnHy\",\"model\":\"claude-haiku-4.5\",\"base_url\":\"https://api.ppq.ai\",\"max_tokens\":512,\"temperature\":0.7},\"didactyl\":{\"admin_pubkey\":\"npub1rmz9gu6de0m0u4ysrn39crrud099ahvfgs6pvasl4hpjr5ud7yus54xv06\",\"dm_protocol\":\"nip04\",\"max_turns\":40}}"
}
],
// ─── Startup Events ───────────────────────────────────────────────
// Minimal relay list required for relay pool initialization.
"startup_events": [
{
"kind": 10002,
"content": "",
"tags": [
[
"r",
"wss://relay.damus.io"
],
[
"r",
"wss://relay.primal.net"
],
[
"r",
"wss://relay.laantungir.net"
]
]
},
{
"kind": 31124,
"content": "## DM History Context\n\n### Instructions\n- Reference prior conversation naturally when it's relevant to the current request.\n- Do not repeat entire DM history back to the user unless explicitly asked.\n- Use this context to avoid asking questions that were already answered in recent messages.\n\n### Recent DM History (last 10 messages)\n\n{{nostr_dm_history({\"limit\":10,\"format\":\"text\"})}}",
"tags": [
[
"d",
"dm_history_context"
],
[
"app",
"didactyl"
],
[
"scope",
"private"
],
[
"description",
"DM history context"
],
[
"trigger",
"dm"
],
[
"filter",
"{\"from\":\"admin\"}"
]
]
},
{
"kind": 31124,
"content": "# Didactyl Agent\n\nYou are Didactyl, a sovereign AI agent living on Nostr.\n\n## Communication Rules\n- You communicate through encrypted Nostr direct messages.\n- Keep responses concise and clear.\n\n## Behavior\n- Be helpful and technically accurate.\n- If unsure, state uncertainty directly.\n- Prefer actionable, practical advice.\n- Use the person's name when messaging them if you know it.\n- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n## Tool Use Policy\n- You have tools available and should use them when a request requires taking action.\n- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.\n- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n- After a tool call, base your answer on the actual tool result.\n- Never claim a tool was run if no tool was executed.\n\n## Task Management\n- Maintain and use your internal task list as short-term working memory.\n- Break long or complex actions into clear tasks before executing them.\n- Update task status as you complete steps so your plan stays accurate.\n\n## Safety\n- Do not claim to have executed actions you did not execute.\n- You may share your public key (npub) with anyone.\n- Never reveal your private key (nsec) under any circumstance.\n\n---template---\n\n- section: admin_identity\n role: system\n tool: admin_identity\n skip_if_empty: true\n\n- section: admin_profile\n role: system\n tool: nostr_admin_profile\n skip_if_empty: true\n\n- section: admin_contacts\n role: system\n tool: nostr_admin_contacts\n skip_if_empty: true\n\n- section: admin_relays\n role: system\n tool: nostr_admin_relays\n skip_if_empty: true\n\n- section: admin_notes\n role: system\n tool: nostr_admin_notes\n skip_if_empty: true\n\n- section: agent_identity\n role: system\n tool: agent_identity\n skip_if_empty: true\n\n- section: agent_profile\n role: system\n tool: nostr_agent_profile\n skip_if_empty: true\n\n- section: agent_contacts\n role: system\n tool: nostr_agent_contacts\n skip_if_empty: true\n\n- section: agent_relays\n role: system\n tool: nostr_agent_relays\n skip_if_empty: true\n\n- section: agent_notes\n role: system\n tool: nostr_agent_notes\n skip_if_empty: true\n\n- section: tasks\n role: system\n tool: task_list\n skip_if_empty: true\n\n- section: dm_history\n role: expand\n limit: 12\n\n- section: conversation\n role: user\n tool: message_current\n skip_if_empty: true",
"tags": [
[
"d",
"didactyl-default"
],
[
"app",
"didactyl"
],
[
"scope",
"private"
]
]
}
]
}

View File

@@ -0,0 +1,285 @@
# Global LLM Config Alignment — Plan
Align LLM configuration storage across Didactyl (C agent) and client-ndk (web pages) so both projects read and write the same canonical schema, as specified in [SETTINGS.md](../../client-ndk/docs/SETTINGS.md).
---
## Problem Statement
LLM provider config is currently stored in two incompatible ways:
| Project | Storage | d-tag | Namespace | Schema |
|---------|---------|-------|-----------|--------|
| Didactyl agent | `kind 30078` under agent pubkey | `llm_config` | flat root | `{ provider, api_key, model, base_url, max_tokens, temperature }` |
| client-ndk pages | `kind 30078` under user pubkey | `user-settings` | `settings.ai` | `{ provider, api_key, model, base_url, max_tokens, temperature, providers[], favorites[] }` |
| skills-edit.html | `kind 30078` under user pubkey | `llm_config` | flat root | Same as Didactyl — standalone event |
This creates three issues:
1. **skills-edit.html** writes a standalone `d:llm_config` event under the user pubkey, duplicating the centralized `settings.ai` data
2. **Didactyl** cannot read the admin user's LLM preferences from their `d:user-settings` event
3. **Namespace mismatch** — the centralized settings use `ai` (v1) / `global_llm` (v2 target) while Didactyl uses flat fields at root level
---
## Target State
Per [SETTINGS.md §3](../../client-ndk/docs/SETTINGS.md) and [§8](../../client-ndk/docs/SETTINGS.md):
### Canonical Schema — `global_llm` namespace
```json
{
"provider": "ppq",
"api_key": "sk-...",
"model": "claude-opus-4.6",
"base_url": "https://api.ppq.ai",
"max_tokens": 200000,
"temperature": 0.7,
"providers": [
{
"name": "ppq",
"base_url": "https://api.ppq.ai",
"api_key": "sk-...",
"models": ["claude-opus-4.6", "claude-haiku-4.5"]
}
],
"favorites": ["claude-opus-4.6"]
}
```
### Storage locations after alignment
| Actor | Event | d-tag | Where LLM lives | Notes |
|-------|-------|-------|------------------|-------|
| User pubkey | `kind 30078` | `user-settings` | `global_llm` namespace | Canonical source for user LLM prefs |
| Agent pubkey | `kind 30078` | `llm_config` | flat root | Agent runtime config — flat fields are a subset of `global_llm` |
| User pubkey | `kind 30078` | `llm_config` | **DEPRECATED** | skills-edit.html stops writing this |
### Cross-project reading
```mermaid
graph LR
subgraph User Pubkey Events
US[d:user-settings<br/>global_llm namespace]
end
subgraph Agent Pubkey Events
AL[d:llm_config<br/>flat fields]
end
subgraph Consumers
SE[skills-edit.html]
AI[ai.html / skills-tv.html]
DA[Didactyl Agent]
end
SE -->|getUserSettings - global_llm| US
AI -->|getUserSettings - global_llm| US
DA -->|recall own d:llm_config| AL
DA -->|optionally read admin global_llm| US
```
---
## Detailed Changes
### Phase 1: genesis.jsonc alignment
**File**: [`genesis.jsonc`](../../didactyl/genesis.jsonc)
Current `"llm"` section already uses compatible flat field names. The only issue is `provider` contains a URL (`"https://api.ppq.ai"`) instead of a short name (`"ppq"`).
**Change**: Update `provider` to be a short name. The `base_url` field already carries the URL.
```jsonc
"llm": {
"provider": "ppq", // was "https://api.ppq.ai"
"api_key": "sk-...",
"model": "claude-opus-4.6",
"base_url": "https://api.ppq.ai",
"max_tokens": 200000,
"temperature": 0.7
}
```
Also update [`genesis.jsonc.example`](../../didactyl/genesis.jsonc.example) to match.
**No C struct changes needed**`llm_config_t.provider` is `char[32]` which holds short names fine.
### Phase 2: Didactyl C-side — persist writes global_llm-compatible fields
**File**: [`src/tools/tool_model.c`](../../didactyl/src/tools/tool_model.c:33) — `persist_llm_config_nostr()`
Already writes: `provider`, `api_key`, `model`, `base_url`, `max_tokens`, `temperature` — these are the exact flat fields in `global_llm`. **No change needed** to the persist path.
**File**: [`src/main.c`](../../didactyl/src/main.c:844) — `persist_runtime_config_to_nostr()`
Same flat fields. **No change needed**.
### Phase 3: Didactyl C-side — recall from admin `d:user-settings`
**File**: [`src/main.c`](../../didactyl/src/main.c:882) — `recover_missing_runtime_config_from_nostr()`
Currently only queries the agent's own pubkey for `d:llm_config`. Add a fallback path:
1. After failing to find own `d:llm_config`, query admin pubkey for `d:user-settings`
2. Decrypt with NIP-44 using admin pubkey as sender
3. Parse JSON, extract `global_llm` object (fall back to `ai` for v1 compat)
4. Pass the extracted object to `apply_recalled_llm_config()`
**File**: [`src/main.c`](../../didactyl/src/main.c:729) — `apply_recalled_llm_config()`
Already reads flat fields from a JSON object. If we pass it the `global_llm` sub-object, it works as-is. It ignores unknown fields like `providers` and `favorites`. **No change needed**.
**New function**: `fetch_admin_user_settings_llm()` in `main.c`:
```c
static int fetch_admin_user_settings_llm(didactyl_config_t* cfg, char** out_plaintext) {
// 1. Query kind:30078, authors:[admin_pubkey], #d:["user-settings"]
// 2. NIP-44 decrypt content (admin encrypted to self — agent cannot decrypt)
// WAIT: Agent cannot decrypt admin's self-encrypted content!
// This path only works if admin explicitly shares config with agent.
// Alternative: Agent reads its OWN d:user-settings if one exists.
// 3. Parse JSON, extract global_llm or ai sub-object
// 4. Serialize sub-object to *out_plaintext
}
```
**Important constraint**: The admin's `d:user-settings` is NIP-44 self-encrypted (admin encrypts to admin). The agent cannot decrypt it because it does not have the admin's private key.
**Revised approach**: Instead of reading the admin's settings directly, the agent should:
1. **Primary**: Read its own `d:llm_config` (current behavior — works)
2. **Secondary**: If the admin wants to push LLM config to the agent, they use the `model_set` tool via DM, which calls [`persist_llm_config_nostr()`](../../didactyl/src/tools/tool_model.c:33) to write `d:llm_config` under the agent pubkey
3. **New path**: The web UI (skills-edit or a future agent-config page) can write `d:llm_config` under the **agent's** pubkey by publishing a NIP-44 encrypted event addressed to the agent. The agent can then decrypt this on recall.
Actually, re-reading [SETTINGS.md §8](../../client-ndk/docs/SETTINGS.md):
> When Didactyl wants to know the user's LLM preferences:
> `kind:30078, authors:[admin_pubkey], #d:[user-settings]`
> Parse the `global_llm` field.
This implies the agent CAN read it. But the content is NIP-44 self-encrypted by the admin. The agent would need the admin to encrypt a copy for the agent, OR the admin publishes their settings unencrypted (unlikely for API keys), OR there is a shared-secret mechanism.
**Resolution**: The practical cross-project path is:
- The **web UI** reads the user's own `global_llm` from `d:user-settings` (it can decrypt its own data)
- The **web UI** can optionally push config to the agent's `d:llm_config` event (encrypt to agent pubkey)
- The **agent** reads its own `d:llm_config` as today
- If the agent needs admin LLM config at startup, the genesis.jsonc provides it, and the setup wizard persists it to `d:llm_config`
So Phase 3 simplifies to: **no C-side recall changes needed for reading admin settings**. The alignment is about ensuring the JSON field names are compatible so the web UI can bridge the two.
### Phase 4: skills-edit.html — migrate to centralized global_llm
**File**: [`../client-ndk/www/skills-edit.html`](../../client-ndk/www/skills-edit.html:1076)
Current behavior:
- [`fetchNostrLlmConfig()`](../../client-ndk/www/skills-edit.html:1076) subscribes to `kind:30078, #d:['llm_config']` under user pubkey
- Decrypts and parses the standalone event
- Merges into `aiConfig`
New behavior:
- Remove `fetchNostrLlmConfig()` entirely
- In [`initializeLlmHelperFromSettings()`](../../client-ndk/www/skills-edit.html:1195), read from `pageSettings.global_llm` (with fallback to `pageSettings.ai` for v1 compat)
- The `pageSettings` object is already populated by `getUserSettings()` and kept live by `onUserSettings()`
- When the user changes provider/model via the LLM helper, patch back via `patchUserSettings({ global_llm: { ... } })`
**Specific changes**:
1. Remove `fetchNostrLlmConfig()` function (~120 lines)
2. Remove `llmConfigLoadNonce` variable
3. Update `initializeLlmHelperFromSettings()`:
```js
async function initializeLlmHelperFromSettings() {
aiConfig = loadAiConfigLocal(aiConfig || getDefaultAiConfig(), AI_STORAGE_KEY);
// Read from centralized settings (global_llm with ai fallback)
const nostrLlm = pageSettings?.global_llm || pageSettings?.ai;
if (nostrLlm && typeof nostrLlm === 'object') {
aiConfig = mergeAiConfigFromSettings(aiConfig, nostrLlm);
}
aiConfig = saveAiConfigLocal(aiConfig, AI_STORAGE_KEY);
renderLlmProviderOptions();
renderLlmModelOptions();
syncLlmHelperFromSkillInput();
fetchLlmModels();
}
```
4. Update `selectLlmProvider()` and `toggleCurrentModelFavorite()` to also patch centralized settings:
```js
// After saving to local storage, also persist to centralized settings
await patchUserSettings({
global_llm: {
provider: aiConfig.provider,
api_key: aiConfig.api_key,
model: aiConfig.model,
base_url: aiConfig.base_url,
max_tokens: aiConfig.max_tokens,
temperature: aiConfig.temperature,
providers: aiConfig.providers,
favorites: aiConfig.favorites
}
});
```
5. Add `patchUserSettings` to the imports from `init-ndk.mjs`
### Phase 5: ai-ui.mjs — support global_llm namespace
**File**: [`../client-ndk/www/js/ai-ui.mjs`](../../client-ndk/www/js/ai-ui.mjs:72)
The [`mergeAiConfigFromSettings()`](../../client-ndk/www/js/ai-ui.mjs:72) function already accepts any object with the right flat fields. It works with both `settings.ai` and `settings.global_llm` — the caller just passes the right sub-object.
**No changes needed to ai-ui.mjs itself.** The callers (skills-edit.html and other pages) just need to read from `global_llm` instead of `ai`.
### Phase 6: Update SETTINGS.md
Mark Issue 1 as resolved. Update the audit findings section to reflect that skills-edit.html now uses centralized `global_llm`.
---
## Files Modified
| File | Project | Change |
|------|---------|--------|
| `genesis.jsonc` | didactyl | Fix `provider` to short name |
| `genesis.jsonc.example` | didactyl | Fix `provider` to short name |
| `../client-ndk/www/skills-edit.html` | client-ndk | Remove `fetchNostrLlmConfig`, read from `pageSettings.global_llm`, add `patchUserSettings` import and calls |
| `../client-ndk/docs/SETTINGS.md` | client-ndk | Mark Issue 1 resolved |
---
## What Does NOT Change
| Component | Why |
|-----------|-----|
| `llm_config_t` C struct | Field names already match `global_llm` flat fields |
| `persist_llm_config_nostr()` in tool_model.c | Already writes compatible flat fields |
| `apply_recalled_llm_config()` in main.c | Already reads compatible flat fields, ignores extras |
| `persist_runtime_config_to_nostr()` in main.c | Already writes compatible flat fields |
| Agent's own `d:llm_config` event | Stays as agent runtime config under agent pubkey |
| `ai-ui.mjs` | `mergeAiConfigFromSettings()` already handles the right shape |
| `ndk-worker.js` | Settings infrastructure unchanged — just a new namespace key |
---
## Migration Safety
- **v1 → v2 compat**: skills-edit.html reads `global_llm || ai` so it works with both old and new settings
- **Local storage fallback**: `loadAiConfigLocal()` still provides defaults if no Nostr settings exist
- **No breaking change for agent**: Agent continues reading its own `d:llm_config` — the flat field names are already compatible
- **Standalone `d:llm_config` under user pubkey**: Becomes orphaned but harmless. Can be cleaned up later.
---
## Encryption Constraint
The agent **cannot** decrypt the admin's `d:user-settings` because it is NIP-44 self-encrypted by the admin. Cross-project LLM config sharing works through:
1. **Genesis file** — admin provides initial LLM config
2. **Setup wizard** — persists to agent's own `d:llm_config`
3. **model_set tool** — admin sends DM to update agent LLM config at runtime
4. **Future**: Web UI could publish a `d:llm_config` event encrypted to the agent's pubkey
This is the correct architecture — the agent should not need to read the admin's private settings directly.

View File

@@ -0,0 +1,283 @@
# Multi-Model Skill Pipelines
## Overview
Didactyl's skill system already supports executing a series of tasks where each task uses a different LLM model — even from different providers. This document describes what works today, what the limitations are, and what improvements would unlock more powerful multi-step workflows.
---
## What Works Today
### Per-Skill LLM Override
Every skill can declare its own `llm` tag with a provider/model spec and fallback chain:
```
["llm", "anthropic/claude-sonnet-4-20250514, cheap"]
["llm", "openai/gpt-4o-mini"]
["llm", "best"]
```
When a triggered skill fires, the runtime applies the skill's execution parameters (model, temperature, max_tokens) before calling the LLM, then restores the agent defaults afterward. This happens in `apply_trigger_runtime_to_llm_config()` in `src/trigger_manager.c`.
The `llm` tag supports the `provider/model` format. If the tag contains a slash, the runtime parses the provider name and model name separately and overrides both in the LLM config for that execution.
### Chain Triggers Connect Skills Sequentially
The `chain` trigger type fires when another skill completes execution. The `filter` field specifies the source skill's `d` tag:
```json
{
"trigger": "chain",
"filter": "source-skill-d-tag"
}
```
After a triggered skill completes, `trigger_manager_fire_chains()` in `src/trigger_manager.c` looks for all adopted skills with `trigger=chain` whose `filter` matches the completed skill's d-tag, and fires them.
### Combined: Multi-Model Pipeline
By combining per-skill LLM overrides with chain triggers, you can build a pipeline where each step uses a different model:
```
DM arrives
├─ Skill: triage
│ llm: openai/gpt-4o-mini (fast/cheap)
│ trigger: dm
│ → Classifies the request
├─ chain fires ──→ Skill: deep-analysis
│ llm: anthropic/claude-sonnet-4-20250514 (powerful)
│ trigger: chain, filter: triage
│ → Performs thorough analysis
└─ chain fires ──→ Skill: summarize
llm: openai/gpt-4o-mini (cheap)
trigger: chain, filter: deep-analysis
→ Summarizes and DMs admin
```
Each skill gets its own model, temperature, and max_tokens applied independently.
### Execution Parameter Resolution Per Step
For each triggered skill execution:
1. Start with agent/app defaults
2. Apply the skill's `llm` tag (parsed as `provider/model` if slash present)
3. Apply the skill's `temperature` tag if present
4. Apply the skill's `max_tokens` tag if present
5. Execute with those effective settings
6. Restore defaults after the run
---
## Concrete Example
### Skill 1: triage (cheap fast model)
```json
{
"kind": 31123,
"content": "## Triage\n\nClassify the incoming message:\n- If it needs deep research, use the memory_save tool to store the classification and key details.\n- If trivial, respond directly.\n\n{{message}}",
"tags": [
["d", "triage"],
["description", "Fast triage of incoming messages"],
["trigger", "dm"],
["filter", "{\"from\":\"admin\"}"],
["llm", "openai/gpt-4o-mini, cheap"],
["temperature", "0"],
["max_tokens", "200"]
]
}
```
### Skill 2: deep-analysis (powerful model, chains from triage)
```json
{
"kind": 31123,
"content": "## Deep Analysis\n\n{{identity}}\n\nRecall the triage classification from memory. Perform thorough analysis using available tools. Save your findings to memory for the next step.\n\nOriginal request context:\n{{message}}",
"tags": [
["d", "deep-analysis"],
["description", "Thorough analysis with powerful model"],
["trigger", "chain"],
["filter", "triage"],
["llm", "anthropic/claude-sonnet-4-20250514, best"],
["max_tokens", "2000"],
["requires_tool", "memory_recall"],
["requires_tool", "memory_save"],
["requires_tool", "nostr_query"],
["requires_skill", "identity"]
]
}
```
### Skill 3: summarize (cheap model, chains from deep-analysis)
```json
{
"kind": 31123,
"content": "## Summarize\n\nRecall the analysis findings from memory. Write a concise summary and DM it to admin.",
"tags": [
["d", "summarize"],
["description", "Summarize analysis and notify admin"],
["trigger", "chain"],
["filter", "deep-analysis"],
["llm", "openai/gpt-4o-mini, cheap"],
["max_tokens", "500"],
["requires_tool", "memory_recall"],
["requires_tool", "nostr_dm_send"]
]
}
```
---
## Current Capabilities
| Capability | Status | Notes |
|---|---|---|
| Different model per skill | ✅ Works | Via `llm` tag on each skill |
| Different provider per skill | ✅ Works | `provider/model` format in `llm` tag |
| Sequential multi-step pipelines | ✅ Works | Via `chain` trigger type |
| Per-step temperature | ✅ Works | Via `temperature` tag |
| Per-step max_tokens | ✅ Works | Via `max_tokens` tag |
| Fallback chains per skill | ✅ Works | `provider/model, provider/model, cheap` |
| LLM config restore after each step | ✅ Works | Runtime saves/restores global config |
---
## Current Limitations
### 1. No Direct Data Passing Between Chain Steps
**Problem:** Chain triggers fire with the *original* triggering event, not the output of the previous skill. Skill B doesn't automatically receive Skill A's output.
**Current workaround:** Use `memory_save` at the end of each step and `memory_recall` at the start of the next. This works but is fragile — memory is a shared scratchpad, not a structured pipeline bus.
**Potential improvement:** Extend the chain trigger event to include the previous skill's final LLM response text. In `trigger_manager_fire_chains()`, the chain event could carry a `"previous_output"` field that the next skill accesses via `{{triggering_event}}`.
### 2. No Conditional Branching
**Problem:** All chain skills matching a source d-tag fire unconditionally. You can't say "if triage classifies as X, run skill A; if Y, run skill B."
**Current workaround:** The chained skill can check the triggering event or memory and decide to do nothing if the condition doesn't match. But it still fires and consumes an LLM call.
**Potential improvement:** Add an optional `chain_condition` tag that the runtime evaluates before firing. Could be a simple JSON match against the previous output, or a keyword presence check.
### 3. No Parallel Fan-Out
**Problem:** Multiple chain skills matching the same source fire sequentially, not in parallel.
**Current workaround:** This is fine for most use cases. True parallelism would require thread-safe LLM config management.
### 4. Chain Depth Limit of 5
**Problem:** `s_chain_depth` in `trigger_manager_fire_chains()` caps at 5 levels to prevent runaway chains.
**Current workaround:** 5 steps is usually sufficient. For longer pipelines, the last step could use a tool to trigger a new chain externally.
**Potential improvement:** Make the depth limit configurable via genesis config.
### 5. Provider Credentials Are Global
**Problem:** The runtime has one set of API keys per provider. If Skill A uses `anthropic/claude-sonnet-4-20250514` and Skill B uses `openai/gpt-4o`, both providers must be configured in the agent's LLM config. There's no per-skill credential storage.
**Current workaround:** Configure all needed providers in the agent's genesis config or via `model_set` tool. The runtime already supports provider switching via the `provider` field in `llm_config_t`.
**Potential improvement:** None needed for most cases — agents typically have a small number of providers configured globally.
---
## Architecture: How It Works in Code
### Trigger Execution Flow
```
trigger_manager fires skill
├─ Save current llm_config (old_cfg)
├─ apply_trigger_runtime_to_llm_config(trigger, &next_cfg)
│ ├─ Parse llm tag: "anthropic/claude-sonnet-4-20250514"
│ │ ├─ Set cfg->provider = "anthropic"
│ │ └─ Set cfg->model = "claude-sonnet-4-20250514"
│ ├─ Apply max_tokens if present
│ └─ Apply temperature if present
├─ llm_set_config(&next_cfg)
├─ Execute skill (agent_on_trigger)
│ ├─ Build context from triggered skills
│ ├─ Call llm_chat_with_tools_messages()
│ └─ Tool loop until completion
├─ Restore llm_set_config(&old_cfg)
└─ trigger_manager_fire_chains(source_d_tag)
├─ Find chain skills where filter == source_d_tag
├─ For each matching chain skill:
│ ├─ Save config again
│ ├─ Apply chain skill's llm override
│ ├─ Execute chain skill
│ ├─ Restore config
│ └─ Recursively fire chains (depth < 5)
└─ Done
```
### Key Source Files
| File | Role |
|---|---|
| `src/trigger_manager.c` | Trigger matching, chain firing, LLM config override/restore |
| `src/agent.c` | `agent_on_trigger()` — builds context and runs LLM loop |
| `src/llm.c` | `llm_chat_with_tools_messages()` — actual LLM API call |
| `docs/SKILLS.md` | Skill spec including `llm` tag format and chain triggers |
---
## Future Enhancements (Not Yet Implemented)
### Priority 1: Chain Output Forwarding
Pass the previous skill's output to the next chain step via the triggering event:
```c
// In trigger_manager_fire_chains():
cJSON_AddStringToObject(event, "previous_output", last_response_text);
```
The chained skill would access this via `{{triggering_event}}` in its template, seeing:
```json
{
"type": "chain",
"source_d_tag": "triage",
"previous_output": "Classification: needs deep analysis. Key topics: ..."
}
```
### Priority 2: Conditional Chain Firing
Add an optional `chain_condition` tag:
```json
["chain_condition", "{\"previous_output_contains\":\"needs deep analysis\"}"]
```
The runtime would check this before firing the chain skill.
### Priority 3: Configurable Chain Depth
```json
// In genesis.jsonc:
"trigger_chain_max_depth": 10
```
---
## Summary
Multi-model skill pipelines work today using per-skill `llm` tags and `chain` triggers. The main gap is data flow between steps (currently requires memory_save/recall workaround). The system is designed for this use case — each skill execution gets its own model config applied and restored — it just needs better inter-step communication to be truly seamless.

View File

@@ -23,22 +23,11 @@
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
#include "context_format.h"
#include "context_roles.h"
#include "json_to_markdown.h"
static didactyl_config_t* g_cfg = NULL;
static tools_context_t g_tools_ctx;
static struct trigger_manager* g_trigger_manager = NULL;
typedef enum {
CONTEXT_DEBUG_OFF = 0,
CONTEXT_DEBUG_INIT = 1,
CONTEXT_DEBUG_FULL = 2
} context_debug_mode_t;
static context_debug_mode_t g_context_debug_mode = CONTEXT_DEBUG_OFF;
static char g_context_debug_run_stamp[32] = {0};
static int g_context_debug_turn = 0;
#define AGENT_CONTEXT_PART_NAMES_MAX 512
static char* g_context_part_names[AGENT_CONTEXT_PART_NAMES_MAX];
static int g_context_part_names_count = 0;
@@ -263,8 +252,6 @@ static int append_tool_result_message(cJSON* messages, const char* tool_call_id,
return 0;
}
static void append_context_log(const char* sender_pubkey_hex, const char* phase, const char* context_payload);
static char* local_json_error(const char* msg) {
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
@@ -848,7 +835,6 @@ static int handle_slash_command(const char* sender_pubkey_hex, const char* messa
message,
result_json ? result_json : "",
dm_payload ? dm_payload : "");
append_context_log(sender_pubkey_hex, "direct_tool_exec", log_payload);
free(log_payload);
}
@@ -915,125 +901,6 @@ const char* agent_classify_message_part(cJSON* msg, int idx) {
return detect_context_section(role_s, content_s, idx);
}
static char* render_messages_json_as_markdown(const char* messages_json) {
if (!messages_json || messages_json[0] == '\0') {
return NULL;
}
cJSON* root = cJSON_Parse(messages_json);
if (!root || !cJSON_IsArray(root)) {
cJSON_Delete(root);
return NULL;
}
char* out = (char*)malloc(4096);
if (!out) {
cJSON_Delete(root);
return NULL;
}
size_t cap = 4096;
size_t used = 0;
out[0] = '\0';
int n = cJSON_GetArraySize(root);
for (int i = 0; i < n; i++) {
cJSON* msg = cJSON_GetArrayItem(root, i);
if (!msg || !cJSON_IsObject(msg)) {
continue;
}
cJSON* role = cJSON_GetObjectItemCaseSensitive(msg, "role");
const char* role_s = (role && cJSON_IsString(role) && role->valuestring) ? role->valuestring : "message";
if (i > 0) {
if (append_textf_local(&out, &cap, &used, "\n---\n\n") != 0) {
free(out);
cJSON_Delete(root);
return NULL;
}
}
if (append_textf_local(&out,
&cap,
&used,
"**Message %d (%s)**\n\n",
i + 1,
role_s) != 0) {
free(out);
cJSON_Delete(root);
return NULL;
}
cJSON* field = NULL;
cJSON_ArrayForEach(field, msg) {
if (!field || !field->string) {
continue;
}
const char* key = field->string;
if (strcmp(key, "_ts") == 0) {
continue; // Internal timestamp metadata; not useful in human-readable snapshots
}
char* raw_value = NULL;
char* rendered_md = NULL;
if (strcmp(key, "content") == 0 && strcmp(role_s, "tool") == 0 && cJSON_IsString(field) && field->valuestring) {
cJSON* tool_payload = cJSON_Parse(field->valuestring);
if (tool_payload && cJSON_IsObject(tool_payload)) {
cJSON* inner = cJSON_GetObjectItemCaseSensitive(tool_payload, "content");
if (inner && cJSON_IsString(inner) && inner->valuestring) {
cJSON* inner_json = cJSON_Parse(inner->valuestring);
if (inner_json) {
cJSON_ReplaceItemInObjectCaseSensitive(tool_payload, "content", inner_json);
}
}
raw_value = cJSON_PrintUnformatted(tool_payload);
cJSON_Delete(tool_payload);
}
}
if (!raw_value) {
if (cJSON_IsString(field) && field->valuestring) {
raw_value = strdup(field->valuestring);
} else {
raw_value = cJSON_PrintUnformatted(field);
}
}
if (raw_value) {
rendered_md = json_to_markdown(raw_value, 8);
}
const char* display = rendered_md ? rendered_md : (raw_value ? raw_value : "");
if (append_textf_local(&out,
&cap,
&used,
"- **%s**: %s\n",
key,
display) != 0) {
free(rendered_md);
free(raw_value);
free(out);
cJSON_Delete(root);
return NULL;
}
free(rendered_md);
free(raw_value);
}
if (append_textf_local(&out, &cap, &used, "\n") != 0) {
free(out);
cJSON_Delete(root);
return NULL;
}
}
cJSON_Delete(root);
return out;
}
static void clear_context_part_names_locked(void) {
for (int i = 0; i < g_context_part_names_count; i++) {
free(g_context_part_names[i]);
@@ -1082,147 +949,6 @@ static __attribute__((unused)) void template_emit_hook(const char* section_name,
set_context_part_name(message_index, section_name ? section_name : "context_part");
}
static void append_context_log(const char* sender_pubkey_hex, const char* phase, const char* context_payload) {
(void)sender_pubkey_hex;
if (!phase || g_context_debug_mode == CONTEXT_DEBUG_OFF) {
return;
}
int is_initial_phase =
(strcmp(phase, "llm_chat_with_tools_messages") == 0 ||
strcmp(phase, "llm_trigger") == 0);
int is_turn_phase =
(strcmp(phase, "llm_chat_with_tools_turn") == 0 ||
strcmp(phase, "llm_trigger_with_tools_turn") == 0);
if (!is_initial_phase && !is_turn_phase) {
return;
}
if (g_context_debug_mode == CONTEXT_DEBUG_INIT && !is_initial_phase) {
return;
}
const char* safe_payload = context_payload ? context_payload : "";
char* turn_markdown = NULL;
if (is_turn_phase && context_payload && context_payload[0] == '[') {
turn_markdown = render_messages_json_as_markdown(context_payload);
if (turn_markdown) {
safe_payload = turn_markdown;
}
}
int entry_len = snprintf(NULL, 0, "%s\n\n", safe_payload);
if (entry_len < 0) {
return;
}
char* entry = (char*)malloc((size_t)entry_len + 1U);
if (!entry) {
return;
}
snprintf(entry, (size_t)entry_len + 1U, "%s\n\n", safe_payload);
char* old_data = NULL;
size_t old_len = 0;
FILE* in = fopen("context.log.md", "rb");
if (in) {
if (fseek(in, 0, SEEK_END) == 0) {
long sz = ftell(in);
if (sz > 0 && fseek(in, 0, SEEK_SET) == 0) {
old_len = (size_t)sz;
old_data = (char*)malloc(old_len);
if (old_data) {
size_t n = fread(old_data, 1, old_len, in);
if (n != old_len) {
free(old_data);
old_data = NULL;
old_len = 0;
}
} else {
old_len = 0;
}
}
}
fclose(in);
}
FILE* out = fopen("context.log.md", "wb");
if (!out) {
free(old_data);
free(entry);
return;
}
(void)fwrite(entry, 1, (size_t)entry_len, out);
if (old_data && old_len > 0) {
(void)fwrite(old_data, 1, old_len, out);
}
fclose(out);
free(old_data);
if (mkdir("context.logs", 0755) != 0 && errno != EEXIST) {
free(entry);
return;
}
time_t now = time(NULL);
struct tm tm_info;
localtime_r(&now, &tm_info);
if (g_context_debug_run_stamp[0] == '\0') {
strftime(g_context_debug_run_stamp, sizeof(g_context_debug_run_stamp), "%Y%m%dT%H%M%S", &tm_info);
g_context_debug_turn = 0;
}
char snapshot_path[256] = {0};
if (is_initial_phase) {
snprintf(snapshot_path,
sizeof(snapshot_path),
"context.logs/%s_init.md",
g_context_debug_run_stamp);
} else {
g_context_debug_turn++;
snprintf(snapshot_path,
sizeof(snapshot_path),
"context.logs/%s_t%03d.md",
g_context_debug_run_stamp,
g_context_debug_turn);
}
FILE* snapshot = fopen(snapshot_path, "wb");
if (snapshot) {
(void)fwrite(safe_payload, 1, strlen(safe_payload), snapshot);
fclose(snapshot);
}
free(entry);
free(turn_markdown);
}
int agent_set_context_debug_mode(const char* mode) {
if (!mode || mode[0] == '\0' || strcmp(mode, "off") == 0) {
g_context_debug_mode = CONTEXT_DEBUG_OFF;
} else if (strcmp(mode, "init") == 0) {
g_context_debug_mode = CONTEXT_DEBUG_INIT;
} else if (strcmp(mode, "full") == 0) {
g_context_debug_mode = CONTEXT_DEBUG_FULL;
} else {
return -1;
}
g_context_debug_run_stamp[0] = '\0';
g_context_debug_turn = 0;
return 0;
}
void agent_append_context_log(const char* sender_pubkey_hex, const char* phase, const char* context_payload) {
append_context_log(sender_pubkey_hex, phase, context_payload);
}
static __attribute__((unused)) char* build_sender_verification_text(didactyl_sender_tier_t sender_tier) {
if (sender_tier == DIDACTYL_SENDER_ADMIN) {
return strdup("This message has been cryptographically verified as coming from your administrator.");
@@ -1579,6 +1305,12 @@ static void clear_adopted_skills_cache_locked(void) {
g_adopted_skills_count = 0;
}
void agent_invalidate_adopted_skills_cache(void) {
pthread_mutex_lock(&g_adopted_skills_mutex);
g_adopted_skills_last_refresh_at = 0;
pthread_mutex_unlock(&g_adopted_skills_mutex);
}
static int refresh_adopted_skills_cache_if_needed(void) {
if (!g_cfg) {
return -1;
@@ -2047,7 +1779,6 @@ void agent_on_trigger(const char* skill_d_tag,
return;
}
append_context_log(g_cfg->admin.pubkey, "llm_trigger", full_markdown);
context_roles_free(&roles);
free(full_markdown);
@@ -2064,8 +1795,6 @@ void agent_on_trigger(const char* skill_d_tag,
break;
}
append_context_log(g_cfg->admin.pubkey, "llm_trigger_with_tools_turn", messages_json);
llm_response_t resp;
int rc = llm_chat_with_tools_messages(messages_json, tools_json, "auto", &resp);
free(messages_json);
@@ -2165,7 +1894,10 @@ int agent_build_admin_messages_json(const char* current_user_message,
(sender_tier == DIDACTYL_SENDER_WOT ? "wot" : "stranger"));
cJSON_AddNumberToObject(dm_event, "created_at", (double)time(NULL));
const char* prev_current_user_message = g_tools_ctx.template_current_user_message;
g_tools_ctx.template_current_user_message = current_user_message ? current_user_message : "";
char* composed_context = build_context_from_triggers(TRIGGER_TYPE_DM, NULL, dm_event, "api");
g_tools_ctx.template_current_user_message = prev_current_user_message;
cJSON_Delete(dm_event);
if (!composed_context) {
composed_context = strdup("You are an AI agent. Respond to the message.");
@@ -2233,7 +1965,6 @@ void agent_on_message(const char* sender_pubkey_hex,
if (message[0] == '/') {
if (!allow_tools) {
const char* denied = "{\"success\":false,\"error\":\"slash commands are disabled for this sender tier\"}";
append_context_log(sender_pubkey_hex, "direct_tool_exec", denied);
(void)nostr_handler_send_dm_auto_with_role(sender_pubkey_hex,
denied,
DIDACTYL_DM_HISTORY_TOOL_RESPONSE);
@@ -2256,24 +1987,15 @@ void agent_on_message(const char* sender_pubkey_hex,
(tier == DIDACTYL_SENDER_WOT ? "wot" : "stranger"));
cJSON_AddNumberToObject(dm_event, "created_at", (double)time(NULL));
const char* prev_current_user_message = g_tools_ctx.template_current_user_message;
g_tools_ctx.template_current_user_message = message ? message : "";
char* dm_context = build_context_from_triggers(TRIGGER_TYPE_DM, NULL, dm_event, "dm");
g_tools_ctx.template_current_user_message = prev_current_user_message;
cJSON_Delete(dm_event);
if (!dm_context) {
dm_context = strdup("You are an AI agent. Respond to the message.");
}
size_t full_len = strlen(dm_context) + strlen("\n\nuser:\n") + strlen(message) + 1U;
char* full_markdown = (char*)malloc(full_len);
if (!full_markdown) {
free(dm_context);
return;
}
snprintf(full_markdown, full_len, "%s\n\nuser:\n%s", dm_context, message);
free(dm_context);
context_roles_t roles;
if (context_roles_split(full_markdown, &roles) != 0) {
free(full_markdown);
if (!dm_context) {
return;
}
@@ -2282,22 +2004,18 @@ void agent_on_message(const char* sender_pubkey_hex,
? "You are responding to a web-of-trust contact. Keep the response helpful and concise. Tool use is disabled for this tier."
: "You are responding in chat-only mode. Tool use is disabled.";
size_t ctx_len = strlen(roles.system_content ? roles.system_content : "") + strlen("\n\n") + strlen(tier_prefix) + 1U;
size_t ctx_len = strlen(dm_context) + strlen("\n\n") + strlen(tier_prefix) + 1U;
char* system_for_chat = (char*)malloc(ctx_len);
if (!system_for_chat) {
context_roles_free(&roles);
free(full_markdown);
free(dm_context);
return;
}
snprintf(system_for_chat, ctx_len, "%s\n\n%s", roles.system_content ? roles.system_content : "", tier_prefix);
snprintf(system_for_chat, ctx_len, "%s\n\n%s", dm_context, tier_prefix);
append_context_log(sender_pubkey_hex, "llm_chat", full_markdown);
char* response = llm_chat(system_for_chat, roles.user_content ? roles.user_content : message);
char* response = llm_chat(system_for_chat, message);
free(system_for_chat);
context_roles_free(&roles);
free(full_markdown);
free(dm_context);
if (!response) {
const char* fallback = "I could not get a response from the LLM right now.";
@@ -2316,20 +2034,18 @@ void agent_on_message(const char* sender_pubkey_hex,
char* tools_json = tools_build_openai_schema_json(&g_tools_ctx);
if (!tools_json) {
context_roles_free(&roles);
free(full_markdown);
free(dm_context);
(void)nostr_handler_send_dm_auto(sender_pubkey_hex, "Tool schema generation failed.");
return;
}
cJSON* messages = cJSON_CreateArray();
if (!messages ||
append_simple_message(messages, "system", roles.system_content ? roles.system_content : "You are an AI agent. Respond to the message.") != 0 ||
append_simple_message(messages, "user", roles.user_content ? roles.user_content : message) != 0) {
append_simple_message(messages, "system", dm_context) != 0 ||
append_simple_message(messages, "user", message) != 0) {
cJSON_Delete(messages);
free(tools_json);
context_roles_free(&roles);
free(full_markdown);
free(dm_context);
(void)nostr_handler_send_dm_auto(sender_pubkey_hex, "Failed to initialize conversation messages.");
return;
}
@@ -2339,7 +2055,7 @@ void agent_on_message(const char* sender_pubkey_hex,
cJSON_AddNumberToObject(live_user_msg, "_ts", (double)time(NULL));
}
int max_turns = g_cfg->tools.max_turns > 0 ? g_cfg->tools.max_turns : 20;
int max_turns = g_cfg->tools.max_turns > 0 ? g_cfg->tools.max_turns : 40;
int stall_repeat_threshold = g_cfg->tools.stall_repeat_threshold > 1 ? g_cfg->tools.stall_repeat_threshold : 3;
uint64_t last_tool_fp = 0;
int repeated_tool_turns = 0;
@@ -2348,8 +2064,6 @@ void agent_on_message(const char* sender_pubkey_hex,
char* final_answer_owned = NULL;
int turns_run = 0;
append_context_log(sender_pubkey_hex, "llm_chat_with_tools_messages", full_markdown);
for (int turn = 0; turn < max_turns; turn++) {
turns_run = turn + 1;
char* messages_json = cJSON_PrintUnformatted(messages);
@@ -2357,8 +2071,6 @@ void agent_on_message(const char* sender_pubkey_hex,
break;
}
append_context_log(sender_pubkey_hex, "llm_chat_with_tools_turn", messages_json);
llm_response_t resp;
int rc = llm_chat_with_tools_messages(messages_json, tools_json, "auto", &resp);
free(messages_json);
@@ -2366,8 +2078,7 @@ void agent_on_message(const char* sender_pubkey_hex,
(void)nostr_handler_send_dm_auto(sender_pubkey_hex, "LLM request failed.");
cJSON_Delete(messages);
free(tools_json);
context_roles_free(&roles);
free(full_markdown);
free(dm_context);
return;
}
@@ -2420,8 +2131,7 @@ void agent_on_message(const char* sender_pubkey_hex,
llm_response_free(&resp);
cJSON_Delete(messages);
free(tools_json);
context_roles_free(&roles);
free(full_markdown);
free(dm_context);
(void)nostr_handler_send_dm_auto(sender_pubkey_hex, "Failed to append tool result.");
return;
}
@@ -2452,10 +2162,22 @@ void agent_on_message(const char* sender_pubkey_hex,
if (!final_answer_owned) {
final_answer_owned = strdup(exited_on_stall
? "I stopped repeated tool calls after detecting a loop and could not produce a final summary."
: "I hit my tool-use limit for this request.");
: "I reached the tool-turn limit before getting a final response from the model.");
}
const char* final_answer = final_answer_owned ? final_answer_owned : "I hit my tool-use limit for this request.";
if (exhausted_on_max_turns) {
const char* base = final_answer_owned ? final_answer_owned : "I reached the tool-turn limit before getting a final response from the model.";
const char* suffix = "\n\nI have paused to avoid running forever. Reply with 'continue' and I will continue from the current context.";
size_t need = strlen(base) + strlen(suffix) + 1U;
char* with_prompt = (char*)malloc(need);
if (with_prompt) {
snprintf(with_prompt, need, "%s%s", base, suffix);
free(final_answer_owned);
final_answer_owned = with_prompt;
}
}
const char* final_answer = final_answer_owned ? final_answer_owned : "I reached the tool-turn limit before getting a final response from the model.";
if (exited_on_stall || exhausted_on_max_turns) {
notify_admin_limit_diagnostic("dm_agent_loop",
@@ -2478,8 +2200,7 @@ void agent_on_message(const char* sender_pubkey_hex,
free(final_answer_owned);
cJSON_Delete(messages);
free(tools_json);
context_roles_free(&roles);
free(full_markdown);
free(dm_context);
}
void agent_cleanup(void) {

View File

@@ -23,8 +23,7 @@ int agent_build_admin_messages_json(const char* current_user_message,
char** out_messages_json);
tools_context_t* agent_tools_context(void);
const char* agent_classify_message_part(cJSON* msg, int idx);
int agent_set_context_debug_mode(const char* mode);
void agent_append_context_log(const char* sender_pubkey_hex, const char* phase, const char* context_payload);
void agent_invalidate_adopted_skills_cache(void);
void agent_cleanup(void);
#endif

View File

@@ -294,7 +294,7 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
}
if (config->tools.max_turns < 1) {
config->tools.max_turns = 8;
config->tools.max_turns = 40;
}
if (config->tools.trigger_max_turns < 1) {
config->tools.trigger_max_turns = config->tools.max_turns;
@@ -496,6 +496,174 @@ static int parse_dm_protocol_config(cJSON* root, didactyl_config_t* config) {
return 0;
}
static int parse_llm_payload_object(cJSON* llm_obj, didactyl_config_t* config, int require_all_fields) {
if (!llm_obj || !cJSON_IsObject(llm_obj) || !config) {
return -1;
}
if (copy_json_string(llm_obj, "provider", config->llm.provider, sizeof(config->llm.provider), 0) != 0) {
return -1;
}
if (copy_json_string(llm_obj, "api_key", config->llm.api_key, sizeof(config->llm.api_key), require_all_fields) != 0) {
return -1;
}
if (copy_json_string(llm_obj, "model", config->llm.model, sizeof(config->llm.model), require_all_fields) != 0) {
return -1;
}
if (copy_json_string(llm_obj, "base_url", config->llm.base_url, sizeof(config->llm.base_url), require_all_fields) != 0) {
return -1;
}
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(llm_obj, "max_tokens");
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(llm_obj, "temperature");
if (max_tokens && cJSON_IsNumber(max_tokens)) {
config->llm.max_tokens = (int)max_tokens->valuedouble;
} else if (config->llm.max_tokens <= 0) {
config->llm.max_tokens = 512;
}
if (temperature && cJSON_IsNumber(temperature)) {
config->llm.temperature = temperature->valuedouble;
} else if (config->llm.temperature <= 0.0) {
config->llm.temperature = 0.7;
}
if (config->llm.provider[0] == '\0') {
snprintf(config->llm.provider, sizeof(config->llm.provider), "%s", "openai");
}
return 0;
}
static int parse_agent_payload_object(cJSON* agent_obj, didactyl_config_t* config) {
if (!agent_obj || !cJSON_IsObject(agent_obj) || !config) {
return -1;
}
cJSON* admin_pubkey = cJSON_GetObjectItemCaseSensitive(agent_obj, "admin_pubkey");
if (admin_pubkey && cJSON_IsString(admin_pubkey) && admin_pubkey->valuestring && admin_pubkey->valuestring[0] != '\0') {
char decoded[65] = {0};
if (decode_pubkey_hex_or_npub(admin_pubkey->valuestring, decoded) != 0) {
return -1;
}
snprintf(config->admin.pubkey, sizeof(config->admin.pubkey), "%s", decoded);
}
cJSON* dm_protocol = cJSON_GetObjectItemCaseSensitive(agent_obj, "dm_protocol");
if (dm_protocol && cJSON_IsString(dm_protocol) && dm_protocol->valuestring && dm_protocol->valuestring[0] != '\0') {
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;
}
}
cJSON* max_turns = cJSON_GetObjectItemCaseSensitive(agent_obj, "max_turns");
if (max_turns) {
if (!cJSON_IsNumber(max_turns)) {
return -1;
}
int parsed = (int)max_turns->valuedouble;
if (parsed < 1) {
parsed = 40;
}
config->tools.max_turns = parsed;
}
return 0;
}
static int parse_user_settings_payload_object(cJSON* user_settings_obj, didactyl_config_t* config) {
if (!user_settings_obj || !cJSON_IsObject(user_settings_obj) || !config) {
return -1;
}
cJSON* global_llm = cJSON_GetObjectItemCaseSensitive(user_settings_obj, "global_llm");
if (!global_llm || !cJSON_IsObject(global_llm) ||
parse_llm_payload_object(global_llm, config, 1) != 0) {
return -1;
}
cJSON* didactyl = cJSON_GetObjectItemCaseSensitive(user_settings_obj, "didactyl");
if (!didactyl || !cJSON_IsObject(didactyl) ||
parse_agent_payload_object(didactyl, config) != 0) {
return -1;
}
return 0;
}
static int parse_encrypted_events(cJSON* root, didactyl_config_t* config) {
cJSON* arr = cJSON_GetObjectItemCaseSensitive(root, "encrypted_events");
if (!arr) {
return 0;
}
if (!cJSON_IsArray(arr)) {
return -1;
}
int count = cJSON_GetArraySize(arr);
if (count <= 0) {
return 0;
}
config->encrypted_events = (encrypted_event_t*)calloc((size_t)count, sizeof(encrypted_event_t));
if (!config->encrypted_events) {
return -1;
}
config->encrypted_event_count = count;
int user_settings_seen = 0;
for (int i = 0; i < count; i++) {
cJSON* item = cJSON_GetArrayItem(arr, i);
if (!item || !cJSON_IsObject(item)) {
return -1;
}
cJSON* kind = cJSON_GetObjectItemCaseSensitive(item, "kind");
cJSON* d_tag = cJSON_GetObjectItemCaseSensitive(item, "d_tag");
cJSON* content = cJSON_GetObjectItemCaseSensitive(item, "content");
if (!kind || !cJSON_IsNumber(kind) ||
!d_tag || !cJSON_IsString(d_tag) || !d_tag->valuestring || d_tag->valuestring[0] == '\0' ||
!content || !cJSON_IsString(content) || !content->valuestring) {
return -1;
}
encrypted_event_t* ee = &config->encrypted_events[i];
ee->kind = (int)kind->valuedouble;
ee->d_tag = strdup(d_tag->valuestring);
ee->content = strdup(content->valuestring);
if (!ee->d_tag || !ee->content) {
return -1;
}
if (ee->kind == 30078 && strcmp(ee->d_tag, "user-settings") == 0 && ee->content[0] != '\0') {
cJSON* user_settings_payload = cJSON_Parse(ee->content);
if (!user_settings_payload || !cJSON_IsObject(user_settings_payload) ||
parse_user_settings_payload_object(user_settings_payload, config) != 0) {
cJSON_Delete(user_settings_payload);
return -1;
}
cJSON_Delete(user_settings_payload);
user_settings_seen = 1;
}
}
if (!user_settings_seen) {
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)) {
@@ -1244,6 +1412,14 @@ void config_free(didactyl_config_t* config) {
free_cashu_wallet_mints(&config->cashu_wallet);
if (config->encrypted_events) {
for (int i = 0; i < config->encrypted_event_count; i++) {
free(config->encrypted_events[i].d_tag);
free(config->encrypted_events[i].content);
}
free(config->encrypted_events);
}
memset(config, 0, sizeof(*config));
}
@@ -1260,7 +1436,7 @@ int config_load(const char* path, didactyl_config_t* config) {
config->dm_protocol = DM_PROTOCOL_NIP04;
config->tools.enabled = 1;
config->tools.max_turns = 20;
config->tools.max_turns = 40;
config->tools.trigger_max_turns = 12;
config->tools.api_default_max_turns = 8;
config->tools.api_max_turns_ceiling = 32;
@@ -1351,9 +1527,8 @@ int config_load(const char* path, didactyl_config_t* config) {
cJSON* admin = cJSON_GetObjectItemCaseSensitive(root, "admin");
cJSON* llm = cJSON_GetObjectItemCaseSensitive(root, "llm");
if (!admin || !cJSON_IsObject(admin) ||
!llm || !cJSON_IsObject(llm)) {
config_set_error("config must include object sections: admin, llm");
if (!admin || !cJSON_IsObject(admin)) {
config_set_error("config must include object section: admin");
goto cleanup;
}
@@ -1381,6 +1556,11 @@ int config_load(const char* path, didactyl_config_t* config) {
goto cleanup;
}
if (parse_encrypted_events(root, config) != 0) {
config_set_error("invalid encrypted_events configuration");
goto cleanup;
}
if (parse_legacy_default_skill_to_startup_events(root, config) != 0) {
config_set_error("invalid legacy default_skill configuration");
goto cleanup;
@@ -1391,32 +1571,16 @@ int config_load(const char* path, didactyl_config_t* config) {
goto cleanup;
}
if (copy_json_string(llm, "provider", config->llm.provider, sizeof(config->llm.provider), 0) != 0) {
config_set_error("llm.provider must be a string if provided");
goto cleanup;
if (llm) {
if (!cJSON_IsObject(llm)) {
config_set_error("llm must be an object when provided");
goto cleanup;
}
if (parse_llm_payload_object(llm, config, 1) != 0) {
config_set_error("llm configuration is invalid (required: api_key, model, base_url)");
goto cleanup;
}
}
if (config->llm.provider[0] == '\0') {
strcpy(config->llm.provider, "openai");
}
if (copy_json_string(llm, "api_key", config->llm.api_key, sizeof(config->llm.api_key), 1) != 0) {
config_set_error("llm.api_key is required and must be a string");
goto cleanup;
}
if (copy_json_string(llm, "model", config->llm.model, sizeof(config->llm.model), 1) != 0) {
config_set_error("llm.model is required and must be a string");
goto cleanup;
}
if (copy_json_string(llm, "base_url", config->llm.base_url, sizeof(config->llm.base_url), 1) != 0) {
config_set_error("llm.base_url is required and must be a string");
goto cleanup;
}
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(llm, "max_tokens");
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(llm, "temperature");
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')");

View File

@@ -64,6 +64,12 @@ typedef struct {
char* tags_json; // JSON array string for tags, optional
} startup_event_t;
typedef struct {
int kind;
char* d_tag;
char* content;
} encrypted_event_t;
typedef struct {
int enabled;
int tools_enabled;
@@ -124,6 +130,8 @@ typedef struct {
cashu_wallet_config_t cashu_wallet;
startup_event_t* startup_events;
int startup_event_count;
encrypted_event_t* encrypted_events;
int encrypted_event_count;
char config_path[OW_MAX_URL_LEN];
} didactyl_config_t;

View File

@@ -882,18 +882,6 @@ static char* execute_slash_command_http(const char* message) {
}
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.");
@@ -902,7 +890,6 @@ static char* execute_slash_command_http(const char* message) {
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;
@@ -945,10 +932,6 @@ static cJSON* run_prompt_with_tools_convo(cJSON* convo,
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);
@@ -1115,7 +1098,6 @@ static cJSON* run_prompt_with_tools(cJSON* body) {
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;
@@ -1379,7 +1361,6 @@ static void handle_prompt_agent(struct mg_connection* c, const struct mg_http_me
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);

View File

@@ -7,6 +7,10 @@
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <time.h>
#include <errno.h>
#include <sys/stat.h>
#include <pthread.h>
#include "cjson/cJSON.h"
#include "debug.h"
@@ -15,6 +19,51 @@
static llm_config_t g_cfg;
static int g_initialized = 0;
static pthread_mutex_t g_llm_request_log_mutex = PTHREAD_MUTEX_INITIALIZER;
static unsigned long g_llm_request_log_seq = 0;
static int provider_log_next_stamp_seq_local(char* stamp, size_t stamp_size, unsigned long* seq_out) {
if (!stamp || stamp_size == 0 || !seq_out) {
return -1;
}
stamp[0] = '\0';
*seq_out = 0;
if (mkdir("context.logs", 0755) != 0 && errno != EEXIST) {
return -1;
}
time_t now = time(NULL);
struct tm tm_info;
localtime_r(&now, &tm_info);
strftime(stamp, stamp_size, "%Y%m%dT%H%M%S", &tm_info);
pthread_mutex_lock(&g_llm_request_log_mutex);
*seq_out = ++g_llm_request_log_seq;
pthread_mutex_unlock(&g_llm_request_log_mutex);
return 0;
}
static void log_provider_payload_local(const char* payload,
const char* stamp,
unsigned long seq,
const char* phase) {
if (!payload || payload[0] == '\0' || !stamp || stamp[0] == '\0' || !phase || phase[0] == '\0') {
return;
}
char path[256] = {0};
snprintf(path, sizeof(path), "context.logs/%s_%s%04lu.json", stamp, phase, seq);
FILE* out = fopen(path, "wb");
if (!out) {
return;
}
(void)fwrite(payload, 1, strlen(payload), out);
fclose(out);
}
static int url_looks_like_websocket(const char* url) {
if (!url) return 0;
@@ -82,6 +131,19 @@ static char* perform_http_request(const char* url, const char* body, int is_post
DEBUG_INFO("[didactyl] llm request: method=GET url=%s", url);
}
char provider_log_stamp[32] = {0};
unsigned long provider_log_seq = 0;
int provider_log_has_pair = 0;
if (is_post && body && body[0] != '\0') {
if (provider_log_next_stamp_seq_local(provider_log_stamp,
sizeof(provider_log_stamp),
&provider_log_seq) == 0) {
log_provider_payload_local(body, provider_log_stamp, provider_log_seq, "req");
provider_log_has_pair = 1;
}
}
nostr_http_response_t resp;
int rc = nostr_http_request(&req, &resp);
if (rc != NOSTR_SUCCESS) {
@@ -89,6 +151,19 @@ static char* perform_http_request(const char* url, const char* body, int is_post
return NULL;
}
if (resp.body && resp.body_len > 0) {
if (!provider_log_has_pair) {
if (provider_log_next_stamp_seq_local(provider_log_stamp,
sizeof(provider_log_stamp),
&provider_log_seq) == 0) {
provider_log_has_pair = 1;
}
}
if (provider_log_has_pair) {
log_provider_payload_local(resp.body, provider_log_stamp, provider_log_seq, "res");
}
}
if (resp.status_code < 200 || resp.status_code >= 300) {
DEBUG_ERROR("[didactyl] llm http request failed: status=%ld", resp.status_code);
if (resp.status_code == 101) {

View File

@@ -107,8 +107,6 @@ static void print_usage(const char* prog) {
" Enable HTTP API and bind address (example: 127.0.0.1).\n"
" --debug <0-5>\n"
" Log level (0=fatal, 1=error, 2=warn, 3=info, 4=debug, 5=trace).\n"
" --context-debug <off|init|full>\n"
" Context snapshot mode (off=none, init=initial only, full=initial+every turn).\n"
" --dump-schemas\n"
" Print tool schemas JSON and exit.\n"
" --test-tool <name> <args_json>\n"
@@ -117,8 +115,6 @@ static void print_usage(const char* prog) {
"Environment:\n"
" DIDACTYL_NSEC\n"
" Fallback private key when --nsec is not provided.\n"
" DIDACTYL_CONTEXT_DEBUG\n"
" Context snapshot mode override: off, init, or full.\n"
"\n"
"Examples:\n"
" 1) Genesis-based startup\n"
@@ -726,7 +722,7 @@ static int fetch_self_config_plaintext(const didactyl_config_t* cfg, const char*
return 0;
}
static int apply_recalled_llm_config(didactyl_config_t* cfg, const char* plaintext) {
static int apply_recalled_user_settings(didactyl_config_t* cfg, const char* plaintext) {
if (!cfg || !plaintext) return -1;
cJSON* root = cJSON_Parse(plaintext);
@@ -735,16 +731,29 @@ static int apply_recalled_llm_config(didactyl_config_t* cfg, const char* plainte
return -1;
}
cJSON* provider = cJSON_GetObjectItemCaseSensitive(root, "provider");
cJSON* api_key = cJSON_GetObjectItemCaseSensitive(root, "api_key");
cJSON* model = cJSON_GetObjectItemCaseSensitive(root, "model");
cJSON* base_url = cJSON_GetObjectItemCaseSensitive(root, "base_url");
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(root, "max_tokens");
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(root, "temperature");
cJSON* global_llm = cJSON_GetObjectItemCaseSensitive(root, "global_llm");
cJSON* didactyl = cJSON_GetObjectItemCaseSensitive(root, "didactyl");
if (!global_llm || !cJSON_IsObject(global_llm) || !didactyl || !cJSON_IsObject(didactyl)) {
cJSON_Delete(root);
return -1;
}
cJSON* provider = cJSON_GetObjectItemCaseSensitive(global_llm, "provider");
cJSON* api_key = cJSON_GetObjectItemCaseSensitive(global_llm, "api_key");
cJSON* model = cJSON_GetObjectItemCaseSensitive(global_llm, "model");
cJSON* base_url = cJSON_GetObjectItemCaseSensitive(global_llm, "base_url");
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(global_llm, "max_tokens");
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(global_llm, "temperature");
cJSON* admin_pubkey = cJSON_GetObjectItemCaseSensitive(didactyl, "admin_pubkey");
cJSON* dm_protocol = cJSON_GetObjectItemCaseSensitive(didactyl, "dm_protocol");
cJSON* max_turns = cJSON_GetObjectItemCaseSensitive(didactyl, "max_turns");
if (!api_key || !cJSON_IsString(api_key) || !api_key->valuestring || api_key->valuestring[0] == '\0' ||
!model || !cJSON_IsString(model) || !model->valuestring || model->valuestring[0] == '\0' ||
!base_url || !cJSON_IsString(base_url) || !base_url->valuestring || base_url->valuestring[0] == '\0') {
!base_url || !cJSON_IsString(base_url) || !base_url->valuestring || base_url->valuestring[0] == '\0' ||
!admin_pubkey || !cJSON_IsString(admin_pubkey) || !admin_pubkey->valuestring || admin_pubkey->valuestring[0] == '\0' ||
!dm_protocol || !cJSON_IsString(dm_protocol) || !dm_protocol->valuestring || dm_protocol->valuestring[0] == '\0') {
cJSON_Delete(root);
return -1;
}
@@ -765,31 +774,20 @@ static int apply_recalled_llm_config(didactyl_config_t* cfg, const char* plainte
cfg->llm.temperature = temperature->valuedouble;
}
cJSON_Delete(root);
return 0;
}
static int apply_recalled_agent_config(didactyl_config_t* cfg, const char* plaintext) {
if (!cfg || !plaintext) return -1;
cJSON* root = cJSON_Parse(plaintext);
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
return -1;
}
cJSON* admin_pubkey = cJSON_GetObjectItemCaseSensitive(root, "admin_pubkey");
cJSON* dm_protocol = cJSON_GetObjectItemCaseSensitive(root, "dm_protocol");
if (admin_pubkey && cJSON_IsString(admin_pubkey) && admin_pubkey->valuestring && admin_pubkey->valuestring[0] != '\0') {
{
char decoded[65] = {0};
if (decode_pubkey_hex_or_npub_local(admin_pubkey->valuestring, decoded) == 0) {
snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", decoded);
if (decode_pubkey_hex_or_npub_local(admin_pubkey->valuestring, decoded) != 0) {
cJSON_Delete(root);
return -1;
}
snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", decoded);
}
if (dm_protocol && cJSON_IsString(dm_protocol) && dm_protocol->valuestring && dm_protocol->valuestring[0] != '\0') {
dm_protocol_from_string(dm_protocol->valuestring, cfg);
dm_protocol_from_string(dm_protocol->valuestring, cfg);
if (max_turns && cJSON_IsNumber(max_turns)) {
int parsed = (int)max_turns->valuedouble;
cfg->tools.max_turns = (parsed > 0) ? parsed : 40;
}
cJSON_Delete(root);
@@ -844,39 +842,46 @@ static int publish_encrypted_self_config(const didactyl_config_t* cfg, const cha
static int persist_runtime_config_to_nostr(const didactyl_config_t* cfg, int persist_llm_config) {
if (!cfg) return -1;
int llm_rc = 0;
if (persist_llm_config) {
cJSON* llm = cJSON_CreateObject();
if (!llm) return -1;
cJSON_AddStringToObject(llm, "provider", cfg->llm.provider);
cJSON_AddStringToObject(llm, "api_key", cfg->llm.api_key);
cJSON_AddStringToObject(llm, "model", cfg->llm.model);
cJSON_AddStringToObject(llm, "base_url", cfg->llm.base_url);
cJSON_AddNumberToObject(llm, "max_tokens", cfg->llm.max_tokens);
cJSON_AddNumberToObject(llm, "temperature", cfg->llm.temperature);
char* llm_json = cJSON_PrintUnformatted(llm);
cJSON_Delete(llm);
if (!llm_json) return -1;
llm_rc = publish_encrypted_self_config(cfg, "llm_config", llm_json);
free(llm_json);
} else {
DEBUG_INFO("[didactyl] startup phase: skipping llm_config publish because it was recalled from Nostr");
if (!persist_llm_config) {
DEBUG_INFO("[didactyl] startup phase: skipping user-settings publish because runtime config was recalled from Nostr");
return 0;
}
cJSON* agent = cJSON_CreateObject();
if (!agent) return llm_rc;
cJSON_AddStringToObject(agent, "admin_pubkey", cfg->admin.pubkey);
cJSON_AddStringToObject(agent, "dm_protocol", dm_protocol_to_string(cfg->dm_protocol));
char* agent_json = cJSON_PrintUnformatted(agent);
cJSON_Delete(agent);
if (!agent_json) return llm_rc;
cJSON* user_settings = cJSON_CreateObject();
cJSON* global_llm = cJSON_CreateObject();
cJSON* didactyl = cJSON_CreateObject();
if (!user_settings || !global_llm || !didactyl) {
cJSON_Delete(user_settings);
cJSON_Delete(global_llm);
cJSON_Delete(didactyl);
return -1;
}
int agent_rc = publish_encrypted_self_config(cfg, "agent_config", agent_json);
free(agent_json);
cJSON_AddNumberToObject(user_settings, "v", 2);
cJSON_AddNumberToObject(user_settings, "updatedAt", (double)time(NULL));
return (llm_rc == 0 && agent_rc == 0) ? 0 : -1;
cJSON_AddStringToObject(global_llm, "provider", cfg->llm.provider);
cJSON_AddStringToObject(global_llm, "api_key", cfg->llm.api_key);
cJSON_AddStringToObject(global_llm, "model", cfg->llm.model);
cJSON_AddStringToObject(global_llm, "base_url", cfg->llm.base_url);
cJSON_AddNumberToObject(global_llm, "max_tokens", cfg->llm.max_tokens);
cJSON_AddNumberToObject(global_llm, "temperature", cfg->llm.temperature);
cJSON_AddItemToObject(user_settings, "global_llm", global_llm);
cJSON_AddStringToObject(didactyl, "admin_pubkey", cfg->admin.pubkey);
cJSON_AddStringToObject(didactyl, "dm_protocol", dm_protocol_to_string(cfg->dm_protocol));
cJSON_AddNumberToObject(didactyl, "max_turns", cfg->tools.max_turns > 0 ? cfg->tools.max_turns : 40);
cJSON_AddItemToObject(user_settings, "didactyl", didactyl);
char* user_settings_json = cJSON_PrintUnformatted(user_settings);
cJSON_Delete(user_settings);
if (!user_settings_json) {
return -1;
}
int rc = publish_encrypted_self_config(cfg, "user-settings", user_settings_json);
free(user_settings_json);
return rc;
}
static int recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg) {
@@ -884,45 +889,22 @@ static int recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg) {
return 0;
}
int llm_recalled_from_nostr = 0;
if (!llm_config_is_complete(&cfg->llm)) {
char* llm_plaintext = NULL;
if (fetch_self_config_plaintext(cfg, "llm_config", &llm_plaintext) == 0 && llm_plaintext) {
if (apply_recalled_llm_config(cfg, llm_plaintext) == 0) {
llm_recalled_from_nostr = 1;
DEBUG_INFO("[didactyl] startup phase: recovered llm_config from Nostr");
} else {
DEBUG_WARN("[didactyl] startup phase: failed to apply recalled llm_config");
}
} else {
DEBUG_WARN("[didactyl] startup phase: llm_config recall unavailable");
}
free(llm_plaintext);
char* settings_plaintext = NULL;
if (fetch_self_config_plaintext(cfg, "user-settings", &settings_plaintext) != 0 || !settings_plaintext) {
DEBUG_WARN("[didactyl] startup phase: user-settings recall unavailable");
free(settings_plaintext);
return 0;
}
char* agent_plaintext = NULL;
if (fetch_self_config_plaintext(cfg, "agent_config", &agent_plaintext) == 0 && agent_plaintext) {
didactyl_config_t recalled = *cfg;
if (apply_recalled_agent_config(&recalled, agent_plaintext) == 0) {
if (!admin_config_is_complete(cfg)) {
snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", recalled.admin.pubkey);
cfg->dm_protocol = recalled.dm_protocol;
DEBUG_INFO("[didactyl] startup phase: recovered agent_config from Nostr");
} else if (recalled.admin.pubkey[0] != '\0' && strcmp(cfg->admin.pubkey, recalled.admin.pubkey) != 0) {
DEBUG_WARN("[didactyl] startup phase: agent_config admin pubkey differs from loaded config (loaded=%.16s..., nostr=%.16s...); loaded config wins",
cfg->admin.pubkey,
recalled.admin.pubkey);
}
} else {
DEBUG_WARN("[didactyl] startup phase: failed to parse/decrypt recalled agent_config");
}
} else {
DEBUG_WARN("[didactyl] startup phase: agent_config recall unavailable");
if (apply_recalled_user_settings(cfg, settings_plaintext) != 0) {
DEBUG_WARN("[didactyl] startup phase: failed to parse/decrypt recalled user-settings");
free(settings_plaintext);
return 0;
}
free(agent_plaintext);
return llm_recalled_from_nostr;
free(settings_plaintext);
DEBUG_INFO("[didactyl] startup phase: recovered user-settings from Nostr");
return 1;
}
int main(int argc, char** argv) {
@@ -935,8 +917,6 @@ int main(int argc, char** argv) {
int dump_schemas = 0;
const char* test_tool_name = NULL;
const char* test_tool_args = "{}";
const char* context_debug_mode = "off";
didactyl_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
int config_preloaded = 0;
@@ -1000,8 +980,6 @@ int main(int argc, char** argv) {
api_port_override = atoi(argv[++i]);
} else if (strcmp(argv[i], "--api-bind") == 0 && i + 1 < argc) {
api_bind_override = argv[++i];
} else if (strcmp(argv[i], "--context-debug") == 0 && i + 1 < argc) {
context_debug_mode = argv[++i];
} else if (strcmp(argv[i], "--dump-schemas") == 0) {
dump_schemas = 1;
} else if (strcmp(argv[i], "--test-tool") == 0 && i + 2 < argc) {
@@ -1064,22 +1042,6 @@ int main(int argc, char** argv) {
DEBUG_INFO("[didactyl] startup phase: admin override applied from --admin (%.16s...)", cfg.admin.pubkey);
}
{
const char* env_context_debug = getenv("DIDACTYL_CONTEXT_DEBUG");
if (env_context_debug && env_context_debug[0] != '\0' &&
strcmp(context_debug_mode, "off") == 0) {
context_debug_mode = env_context_debug;
}
if (agent_set_context_debug_mode(context_debug_mode) != 0) {
fprintf(stderr, "Invalid context debug mode '%s' (expected off, init, or full)\n", context_debug_mode);
config_free(&cfg);
nostr_cleanup();
return 1;
}
DEBUG_INFO("[didactyl] context debug mode: %s", context_debug_mode);
}
if (config_ensure_startup_skill_adoption(&cfg) != 0) {
fprintf(stderr, "Failed to synthesize startup skill adoption events\n");
config_free(&cfg);
@@ -1223,13 +1185,13 @@ int main(int argc, char** argv) {
startup_step_begin(2, "Recover runtime config from Nostr");
int llm_recalled_from_nostr = recover_missing_runtime_config_from_nostr(&cfg);
startup_step_ok(2, "Recover runtime config from Nostr", llm_recalled_from_nostr ? "llm_config recalled" : NULL);
startup_step_ok(2, "Recover runtime config from Nostr", llm_recalled_from_nostr ? "user-settings recalled" : NULL);
startup_step_begin(3, "Validate LLM config");
if (!llm_config_is_complete(&cfg.llm)) {
startup_step_fail(3, "Validate LLM config", "missing llm_config (model/base_url/api_key)");
startup_step_fail(3, "Validate LLM config", "missing user-settings.global_llm (model/base_url/api_key)");
fprintf(stderr,
"Missing LLM config: provide genesis file values or store d=llm_config via config_store\n");
"Missing LLM config: provide genesis file values or store d=user-settings with global_llm\n");
nostr_block_list_cleanup();
nostr_handler_cleanup();
config_free(&cfg);
@@ -1242,7 +1204,7 @@ int main(int argc, char** argv) {
if (!admin_config_is_complete(&cfg)) {
startup_step_fail(4, "Validate admin config", "missing admin pubkey");
fprintf(stderr,
"Missing admin config: provide admin.pubkey in genesis, pass --admin, or store d=agent_config via config_store\n");
"Missing admin config: provide admin.pubkey in genesis, pass --admin, or store d=user-settings with didactyl.admin_pubkey\n");
nostr_block_list_cleanup();
nostr_handler_cleanup();
config_free(&cfg);
@@ -1284,10 +1246,14 @@ int main(int argc, char** argv) {
DEBUG_INFO("[didactyl] startup phase: existing-agent mode; skipping startup-event reconcile on subsequent run");
}
if (persist_runtime_config_to_nostr(&cfg, llm_recalled_from_nostr ? 0 : 1) != 0) {
DEBUG_WARN("[didactyl] startup phase: failed to persist encrypted runtime config to Nostr");
if (bootstrap_mode || first_run) {
if (persist_runtime_config_to_nostr(&cfg, llm_recalled_from_nostr ? 0 : 1) != 0) {
DEBUG_WARN("[didactyl] startup phase: failed to persist encrypted runtime config to Nostr");
} else {
DEBUG_INFO("[didactyl] startup phase: persisted encrypted runtime config to Nostr");
}
} else {
DEBUG_INFO("[didactyl] startup phase: persisted encrypted runtime config to Nostr");
DEBUG_INFO("[didactyl] startup phase: skipping encrypted runtime config publish on subsequent run");
}
startup_step_ok(7, "Reconcile/persist startup state", NULL);

View File

@@ -12,8 +12,8 @@
// Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define DIDACTYL_VERSION_MAJOR 0
#define DIDACTYL_VERSION_MINOR 2
#define DIDACTYL_VERSION_PATCH 24
#define DIDACTYL_VERSION "v0.2.24"
#define DIDACTYL_VERSION_PATCH 28
#define DIDACTYL_VERSION "v0.2.28"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"

View File

@@ -14,6 +14,7 @@
#include "trigger_manager.h"
#include "nostr_block_list.h"
#include "cashu_wallet.h"
#include "agent.h"
#define NIP17_MAX_RELAYS 32
#define NIP17_MAX_GIFT_WRAPS 8
@@ -1028,6 +1029,287 @@ static const char* find_tag_value_local(cJSON* tags, const char* key) {
return NULL;
}
static int nip44_encrypt_self_local(const char* plaintext, char** out_ciphertext) {
if (!g_cfg || !out_ciphertext) {
return -1;
}
const char* plain = plaintext ? plaintext : "";
size_t out_cap = (strlen(plain) * 4U) + 1024U;
char* ciphertext = (char*)malloc(out_cap);
if (!ciphertext) {
return -1;
}
int rc = nostr_nip44_encrypt(g_cfg->keys.private_key,
g_cfg->keys.public_key,
plain,
ciphertext,
out_cap);
if (rc != NOSTR_SUCCESS) {
free(ciphertext);
return -1;
}
*out_ciphertext = ciphertext;
return 0;
}
static int nip44_decrypt_self_local(const char* ciphertext, char** out_plaintext) {
if (!g_cfg || !ciphertext || !out_plaintext) {
return -1;
}
size_t out_cap = strlen(ciphertext) + 1024U;
char* plaintext = (char*)malloc(out_cap);
if (!plaintext) {
return -1;
}
int rc = nostr_nip44_decrypt(g_cfg->keys.private_key,
g_cfg->keys.public_key,
ciphertext,
plaintext,
out_cap);
if (rc != NOSTR_SUCCESS) {
free(plaintext);
return -1;
}
*out_plaintext = plaintext;
return 0;
}
static cJSON* duplicate_public_d_tags_local(cJSON* tags) {
cJSON* out = cJSON_CreateArray();
if (!out) {
return NULL;
}
if (!tags || !cJSON_IsArray(tags)) {
return out;
}
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);
if (!key || !cJSON_IsString(key) || !key->valuestring || strcmp(key->valuestring, "d") != 0) {
continue;
}
cJSON* dup = cJSON_Duplicate(tag, 1);
if (!dup) {
cJSON_Delete(out);
return NULL;
}
cJSON_AddItemToArray(out, dup);
}
return out;
}
static cJSON* duplicate_private_non_d_tags_local(cJSON* tags) {
cJSON* out = cJSON_CreateArray();
if (!out) {
return NULL;
}
if (!tags || !cJSON_IsArray(tags)) {
return out;
}
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);
if (!key || !cJSON_IsString(key) || !key->valuestring || strcmp(key->valuestring, "d") == 0) {
continue;
}
cJSON* dup = cJSON_Duplicate(tag, 1);
if (!dup) {
cJSON_Delete(out);
return NULL;
}
cJSON_AddItemToArray(out, dup);
}
return out;
}
static int encode_private_skill_event_payload_local(const char* content,
cJSON* tags,
char** out_encrypted_content,
cJSON** out_public_tags) {
if (!out_encrypted_content || !out_public_tags) {
return -1;
}
*out_encrypted_content = NULL;
*out_public_tags = NULL;
cJSON* payload = cJSON_CreateObject();
cJSON* private_tags = duplicate_private_non_d_tags_local(tags);
cJSON* public_tags = duplicate_public_d_tags_local(tags);
if (!payload || !private_tags || !public_tags) {
cJSON_Delete(payload);
cJSON_Delete(private_tags);
cJSON_Delete(public_tags);
return -1;
}
cJSON_AddStringToObject(payload, "content", content ? content : "");
cJSON_AddItemToObject(payload, "private_tags", private_tags);
char* payload_json = cJSON_PrintUnformatted(payload);
cJSON_Delete(payload);
if (!payload_json) {
cJSON_Delete(public_tags);
return -1;
}
char* encrypted_content = NULL;
int rc = nip44_encrypt_self_local(payload_json, &encrypted_content);
free(payload_json);
if (rc != 0) {
cJSON_Delete(public_tags);
return -1;
}
*out_encrypted_content = encrypted_content;
*out_public_tags = public_tags;
return 0;
}
static int tag_tuple_exists_local(cJSON* tags, const char* key, const char* value) {
if (!tags || !cJSON_IsArray(tags) || !key || !value) {
return 0;
}
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* ex_key = cJSON_GetArrayItem(tag, 0);
cJSON* ex_value = cJSON_GetArrayItem(tag, 1);
if (!ex_key || !ex_value ||
!cJSON_IsString(ex_key) || !ex_key->valuestring ||
!cJSON_IsString(ex_value) || !ex_value->valuestring) {
continue;
}
if (strcmp(ex_key->valuestring, key) == 0 && strcmp(ex_value->valuestring, value) == 0) {
return 1;
}
}
return 0;
}
static int merge_private_tags_into_event_local(cJSON* event, cJSON* private_tags) {
if (!event || !cJSON_IsObject(event) || !private_tags || !cJSON_IsArray(private_tags)) {
return -1;
}
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) {
tags = cJSON_CreateArray();
if (!tags) {
return -1;
}
cJSON_AddItemToObject(event, "tags", tags);
}
int n = cJSON_GetArraySize(private_tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(private_tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* value = cJSON_GetArrayItem(tag, 1);
if (!key || !value ||
!cJSON_IsString(key) || !key->valuestring ||
!cJSON_IsString(value) || !value->valuestring) {
continue;
}
if (strcmp(key->valuestring, "d") == 0) {
continue;
}
if (tag_tuple_exists_local(tags, key->valuestring, value->valuestring)) {
continue;
}
cJSON* dup = cJSON_Duplicate(tag, 1);
if (!dup) {
return -1;
}
cJSON_AddItemToArray(tags, dup);
}
return 0;
}
static int normalize_private_skill_event_local(cJSON* event) {
if (!event || !cJSON_IsObject(event)) {
return -1;
}
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content");
if (!kind || !cJSON_IsNumber(kind) || (int)kind->valuedouble != 31124 ||
!content || !cJSON_IsString(content) || !content->valuestring) {
return 0;
}
char* plaintext = NULL;
if (nip44_decrypt_self_local(content->valuestring, &plaintext) != 0) {
return 0;
}
cJSON* payload = cJSON_Parse(plaintext);
if (!payload || !cJSON_IsObject(payload)) {
cJSON_Delete(payload);
free(plaintext);
return 0;
}
cJSON* payload_content = cJSON_GetObjectItemCaseSensitive(payload, "content");
cJSON* private_tags = cJSON_GetObjectItemCaseSensitive(payload, "private_tags");
if (!payload_content || !cJSON_IsString(payload_content) || !payload_content->valuestring ||
!private_tags || !cJSON_IsArray(private_tags)) {
cJSON_Delete(payload);
free(plaintext);
return 0;
}
cJSON* content_item = cJSON_CreateString(payload_content->valuestring);
if (content_item) {
cJSON_ReplaceItemInObject(event, "content", content_item);
}
(void)merge_private_tags_into_event_local(event, private_tags);
cJSON_Delete(payload);
free(plaintext);
return 0;
}
static int parse_enabled_flag_local(const char* enabled_s) {
if (!enabled_s || enabled_s[0] == '\0') {
return 1;
@@ -2142,10 +2424,14 @@ static void on_self_skill_event(cJSON* event, const char* relay_url, void* user_
}
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
int kind_val = (kind && cJSON_IsNumber(kind)) ? (int)kind->valuedouble : -1;
if (kind_val == 31124) {
(void)normalize_private_skill_event_local(event);
}
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
cJSON* id = cJSON_GetObjectItemCaseSensitive(event, "id");
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
int kind_val = (kind && cJSON_IsNumber(kind)) ? (int)kind->valuedouble : -1;
const char* d_tag = (tags && cJSON_IsArray(tags)) ? find_tag_value_local(tags, "d") : NULL;
DEBUG_INFO("[didactyl] self-skill cache received kind=%d d_tag=%s id=%s created_at=%lld via %s",
@@ -2160,6 +2446,8 @@ static void on_self_skill_event(cJSON* event, const char* relay_url, void* user_
pthread_mutex_lock(&g_self_skill_mutex);
self_skill_cache_upsert_event_locked(event);
pthread_mutex_unlock(&g_self_skill_mutex);
agent_invalidate_adopted_skills_cache();
}
static void on_wallet_event(cJSON* event, const char* relay_url, void* user_data) {
@@ -2921,19 +3209,36 @@ static int publish_kind_event_to_relays(int kind,
return -1;
}
const char* content_to_publish = content;
cJSON* tags_to_publish = tags;
char* encrypted_content = NULL;
cJSON* public_tags = NULL;
if (kind == 31124) {
if (encode_private_skill_event_payload_local(content, tags, &encrypted_content, &public_tags) != 0) {
return -1;
}
content_to_publish = encrypted_content;
tags_to_publish = public_tags;
}
cJSON* tags_copy = NULL;
if (tags) {
tags_copy = cJSON_Duplicate(tags, 1);
if (tags_to_publish) {
tags_copy = cJSON_Duplicate(tags_to_publish, 1);
if (!tags_copy) {
free(encrypted_content);
cJSON_Delete(public_tags);
return -1;
}
}
cJSON* event = nostr_create_and_sign_event(kind, content, tags_copy, g_cfg->keys.private_key, time(NULL));
cJSON* event = nostr_create_and_sign_event(kind, content_to_publish, tags_copy, g_cfg->keys.private_key, time(NULL));
if (tags_copy) {
cJSON_Delete(tags_copy);
}
if (!event) {
free(encrypted_content);
cJSON_Delete(public_tags);
return -1;
}
@@ -2966,12 +3271,17 @@ static int publish_kind_event_to_relays(int kind,
sent);
if (sent > 0) {
if (kind == 31124) {
(void)normalize_private_skill_event_local(event);
}
pthread_mutex_lock(&g_self_skill_mutex);
self_skill_cache_upsert_event_locked(event);
pthread_mutex_unlock(&g_self_skill_mutex);
}
cJSON_Delete(event);
free(encrypted_content);
cJSON_Delete(public_tags);
return sent > 0 ? 0 : -1;
}

View File

@@ -271,7 +271,7 @@ static void config_set_defaults(didactyl_config_t* cfg) {
cfg->dm_protocol = DM_PROTOCOL_NIP04;
cfg->tools.enabled = 1;
cfg->tools.max_turns = 20;
cfg->tools.max_turns = 40;
cfg->tools.trigger_max_turns = 12;
cfg->tools.api_default_max_turns = 8;
cfg->tools.api_max_turns_ceiling = 32;
@@ -981,7 +981,7 @@ static int fetch_and_decrypt_self_config_wizard(const didactyl_config_t* cfg, co
return 0;
}
static int apply_recalled_llm_config_wizard(didactyl_config_t* cfg, const char* plaintext) {
static int apply_recalled_user_settings_wizard(didactyl_config_t* cfg, const char* plaintext) {
if (!cfg || !plaintext) return -1;
cJSON* root = cJSON_Parse(plaintext);
@@ -990,16 +990,29 @@ static int apply_recalled_llm_config_wizard(didactyl_config_t* cfg, const char*
return -1;
}
cJSON* provider = cJSON_GetObjectItemCaseSensitive(root, "provider");
cJSON* api_key = cJSON_GetObjectItemCaseSensitive(root, "api_key");
cJSON* model = cJSON_GetObjectItemCaseSensitive(root, "model");
cJSON* base_url = cJSON_GetObjectItemCaseSensitive(root, "base_url");
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(root, "max_tokens");
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(root, "temperature");
cJSON* global_llm = cJSON_GetObjectItemCaseSensitive(root, "global_llm");
cJSON* didactyl = cJSON_GetObjectItemCaseSensitive(root, "didactyl");
if (!global_llm || !cJSON_IsObject(global_llm) || !didactyl || !cJSON_IsObject(didactyl)) {
cJSON_Delete(root);
return -1;
}
cJSON* provider = cJSON_GetObjectItemCaseSensitive(global_llm, "provider");
cJSON* api_key = cJSON_GetObjectItemCaseSensitive(global_llm, "api_key");
cJSON* model = cJSON_GetObjectItemCaseSensitive(global_llm, "model");
cJSON* base_url = cJSON_GetObjectItemCaseSensitive(global_llm, "base_url");
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(global_llm, "max_tokens");
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(global_llm, "temperature");
cJSON* admin_pubkey = cJSON_GetObjectItemCaseSensitive(didactyl, "admin_pubkey");
cJSON* dm_protocol = cJSON_GetObjectItemCaseSensitive(didactyl, "dm_protocol");
cJSON* max_turns = cJSON_GetObjectItemCaseSensitive(didactyl, "max_turns");
if (!api_key || !cJSON_IsString(api_key) || !api_key->valuestring || api_key->valuestring[0] == '\0' ||
!model || !cJSON_IsString(model) || !model->valuestring || model->valuestring[0] == '\0' ||
!base_url || !cJSON_IsString(base_url) || !base_url->valuestring || base_url->valuestring[0] == '\0') {
!base_url || !cJSON_IsString(base_url) || !base_url->valuestring || base_url->valuestring[0] == '\0' ||
!admin_pubkey || !cJSON_IsString(admin_pubkey) || !admin_pubkey->valuestring || admin_pubkey->valuestring[0] == '\0' ||
!dm_protocol || !cJSON_IsString(dm_protocol) || !dm_protocol->valuestring || dm_protocol->valuestring[0] == '\0') {
cJSON_Delete(root);
return -1;
}
@@ -1020,37 +1033,26 @@ static int apply_recalled_llm_config_wizard(didactyl_config_t* cfg, const char*
cfg->llm.temperature = temperature->valuedouble;
}
cJSON_Delete(root);
return 0;
}
static int apply_recalled_agent_config_wizard(didactyl_config_t* cfg, const char* plaintext) {
if (!cfg || !plaintext) return -1;
cJSON* root = cJSON_Parse(plaintext);
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
return -1;
}
cJSON* admin_pubkey = cJSON_GetObjectItemCaseSensitive(root, "admin_pubkey");
cJSON* dm_protocol = cJSON_GetObjectItemCaseSensitive(root, "dm_protocol");
if (admin_pubkey && cJSON_IsString(admin_pubkey) && admin_pubkey->valuestring && admin_pubkey->valuestring[0] != '\0') {
{
char decoded[65] = {0};
if (decode_pubkey_hex_or_npub_local(admin_pubkey->valuestring, decoded) == 0) {
snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", decoded);
if (decode_pubkey_hex_or_npub_local(admin_pubkey->valuestring, decoded) != 0) {
cJSON_Delete(root);
return -1;
}
snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", decoded);
}
if (dm_protocol && cJSON_IsString(dm_protocol) && dm_protocol->valuestring && dm_protocol->valuestring[0] != '\0') {
if (strcmp(dm_protocol->valuestring, "nip17") == 0) {
cfg->dm_protocol = DM_PROTOCOL_NIP17;
} else if (strcmp(dm_protocol->valuestring, "both") == 0) {
cfg->dm_protocol = DM_PROTOCOL_BOTH;
} else {
cfg->dm_protocol = DM_PROTOCOL_NIP04;
}
if (strcmp(dm_protocol->valuestring, "nip17") == 0) {
cfg->dm_protocol = DM_PROTOCOL_NIP17;
} else if (strcmp(dm_protocol->valuestring, "both") == 0) {
cfg->dm_protocol = DM_PROTOCOL_BOTH;
} else {
cfg->dm_protocol = DM_PROTOCOL_NIP04;
}
if (max_turns && cJSON_IsNumber(max_turns)) {
int parsed = (int)max_turns->valuedouble;
cfg->tools.max_turns = (parsed > 0) ? parsed : 40;
}
cJSON_Delete(root);
@@ -1077,17 +1079,11 @@ static int recover_full_config_from_nostr(didactyl_config_t* cfg,
(void)query_self_kind0_name(cfg, out_agent_name, out_agent_name_size, out_agent_name_found);
char* llm_plaintext = NULL;
if (fetch_and_decrypt_self_config_wizard(cfg, "llm_config", &llm_plaintext) == 0 && llm_plaintext) {
(void)apply_recalled_llm_config_wizard(cfg, llm_plaintext);
char* settings_plaintext = NULL;
if (fetch_and_decrypt_self_config_wizard(cfg, "user-settings", &settings_plaintext) == 0 && settings_plaintext) {
(void)apply_recalled_user_settings_wizard(cfg, settings_plaintext);
}
free(llm_plaintext);
char* agent_plaintext = NULL;
if (fetch_and_decrypt_self_config_wizard(cfg, "agent_config", &agent_plaintext) == 0 && agent_plaintext) {
(void)apply_recalled_agent_config_wizard(cfg, agent_plaintext);
}
free(agent_plaintext);
free(settings_plaintext);
nostr_handler_cleanup();
return 0;
@@ -1153,34 +1149,39 @@ static int publish_encrypted_self_config_wizard(const didactyl_config_t* cfg,
static int persist_runtime_config_to_nostr_wizard(const didactyl_config_t* cfg) {
if (!cfg) return -1;
cJSON* llm = cJSON_CreateObject();
if (!llm) return -1;
cJSON_AddStringToObject(llm, "provider", cfg->llm.provider);
cJSON_AddStringToObject(llm, "api_key", cfg->llm.api_key);
cJSON_AddStringToObject(llm, "model", cfg->llm.model);
cJSON_AddStringToObject(llm, "base_url", cfg->llm.base_url);
cJSON_AddNumberToObject(llm, "max_tokens", cfg->llm.max_tokens);
cJSON_AddNumberToObject(llm, "temperature", cfg->llm.temperature);
cJSON* user_settings = cJSON_CreateObject();
cJSON* global_llm = cJSON_CreateObject();
cJSON* didactyl = cJSON_CreateObject();
if (!user_settings || !global_llm || !didactyl) {
cJSON_Delete(user_settings);
cJSON_Delete(global_llm);
cJSON_Delete(didactyl);
return -1;
}
char* llm_json = cJSON_PrintUnformatted(llm);
cJSON_Delete(llm);
if (!llm_json) return -1;
cJSON_AddNumberToObject(user_settings, "v", 2);
cJSON_AddNumberToObject(user_settings, "updatedAt", (double)time(NULL));
int llm_rc = publish_encrypted_self_config_wizard(cfg, "llm_config", llm_json);
free(llm_json);
cJSON_AddStringToObject(global_llm, "provider", cfg->llm.provider);
cJSON_AddStringToObject(global_llm, "api_key", cfg->llm.api_key);
cJSON_AddStringToObject(global_llm, "model", cfg->llm.model);
cJSON_AddStringToObject(global_llm, "base_url", cfg->llm.base_url);
cJSON_AddNumberToObject(global_llm, "max_tokens", cfg->llm.max_tokens);
cJSON_AddNumberToObject(global_llm, "temperature", cfg->llm.temperature);
cJSON_AddItemToObject(user_settings, "global_llm", global_llm);
cJSON* agent = cJSON_CreateObject();
if (!agent) return llm_rc;
cJSON_AddStringToObject(agent, "admin_pubkey", cfg->admin.pubkey);
cJSON_AddStringToObject(agent, "dm_protocol", dm_protocol_to_string_local(cfg->dm_protocol));
char* agent_json = cJSON_PrintUnformatted(agent);
cJSON_Delete(agent);
if (!agent_json) return llm_rc;
cJSON_AddStringToObject(didactyl, "admin_pubkey", cfg->admin.pubkey);
cJSON_AddStringToObject(didactyl, "dm_protocol", dm_protocol_to_string_local(cfg->dm_protocol));
cJSON_AddNumberToObject(didactyl, "max_turns", cfg->tools.max_turns > 0 ? cfg->tools.max_turns : 40);
cJSON_AddItemToObject(user_settings, "didactyl", didactyl);
int agent_rc = publish_encrypted_self_config_wizard(cfg, "agent_config", agent_json);
free(agent_json);
char* settings_json = cJSON_PrintUnformatted(user_settings);
cJSON_Delete(user_settings);
if (!settings_json) return -1;
return (llm_rc == 0 && agent_rc == 0) ? 0 : -1;
int rc = publish_encrypted_self_config_wizard(cfg, "user-settings", settings_json);
free(settings_json);
return rc;
}
static int persist_runtime_config_to_nostr_wizard_online(const didactyl_config_t* cfg) {

View File

@@ -5,6 +5,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "cjson/cJSON.h"
#include "../config.h"
@@ -30,7 +31,9 @@ static cJSON* parse_args_local(const char* args_json) {
return args;
}
static int persist_llm_config_nostr(tools_context_t* ctx, const llm_config_t* cfg, char** out_error) {
static int persist_global_llm_user_settings_nostr(tools_context_t* ctx,
const llm_config_t* cfg,
char** out_error) {
if (!ctx || !ctx->cfg || !cfg) {
if (out_error) *out_error = strdup("invalid persist context");
return -1;
@@ -38,28 +41,136 @@ static int persist_llm_config_nostr(tools_context_t* ctx, const llm_config_t* cf
if (out_error) *out_error = NULL;
cJSON* args = cJSON_CreateObject();
cJSON* content = cJSON_CreateObject();
if (!args || !content) {
cJSON_Delete(args);
cJSON_Delete(content);
if (out_error) *out_error = strdup("allocation failure while building llm_config payload");
cJSON* recall_args = cJSON_CreateObject();
if (!recall_args) {
if (out_error) *out_error = strdup("allocation failure while building config_recall args");
return -1;
}
cJSON_AddStringToObject(recall_args, "d_tag", "user-settings");
char* recall_args_json = cJSON_PrintUnformatted(recall_args);
cJSON_Delete(recall_args);
if (!recall_args_json) {
if (out_error) *out_error = strdup("failed to serialize config_recall args");
return -1;
}
cJSON_AddStringToObject(args, "d_tag", "llm_config");
cJSON_AddStringToObject(content, "provider", cfg->provider);
cJSON_AddStringToObject(content, "api_key", cfg->api_key);
cJSON_AddStringToObject(content, "model", cfg->model);
cJSON_AddStringToObject(content, "base_url", cfg->base_url);
cJSON_AddNumberToObject(content, "max_tokens", cfg->max_tokens);
cJSON_AddNumberToObject(content, "temperature", cfg->temperature);
cJSON_AddItemToObject(args, "content", content);
char* recall_result = execute_config_recall(ctx, recall_args_json);
free(recall_args_json);
if (!recall_result) {
if (out_error) *out_error = strdup("config_recall returned no response");
return -1;
}
char* store_args_json = cJSON_PrintUnformatted(args);
cJSON_Delete(args);
cJSON* recall_root = cJSON_Parse(recall_result);
free(recall_result);
if (!recall_root || !cJSON_IsObject(recall_root)) {
cJSON_Delete(recall_root);
if (out_error) *out_error = strdup("config_recall returned invalid JSON");
return -1;
}
cJSON* recall_success = cJSON_GetObjectItemCaseSensitive(recall_root, "success");
if (!recall_success || !cJSON_IsBool(recall_success) || !cJSON_IsTrue(recall_success)) {
cJSON* err = cJSON_GetObjectItemCaseSensitive(recall_root, "error");
if (out_error) {
if (err && cJSON_IsString(err) && err->valuestring) {
*out_error = strdup(err->valuestring);
} else {
*out_error = strdup("config_recall failed");
}
}
cJSON_Delete(recall_root);
return -1;
}
cJSON* found_j = cJSON_GetObjectItemCaseSensitive(recall_root, "found");
int found = (found_j && cJSON_IsBool(found_j) && cJSON_IsTrue(found_j)) ? 1 : 0;
cJSON* content_json = cJSON_GetObjectItemCaseSensitive(recall_root, "content_json");
cJSON* user_settings = NULL;
if (found && content_json && cJSON_IsObject(content_json)) {
user_settings = cJSON_Duplicate(content_json, 1);
}
if (!user_settings) {
user_settings = cJSON_CreateObject();
}
cJSON_Delete(recall_root);
if (!user_settings) {
if (out_error) *out_error = strdup("allocation failure while preparing user-settings payload");
return -1;
}
cJSON_DeleteItemFromObjectCaseSensitive(user_settings, "v");
cJSON_AddNumberToObject(user_settings, "v", 2);
cJSON_DeleteItemFromObjectCaseSensitive(user_settings, "updatedAt");
cJSON_AddNumberToObject(user_settings, "updatedAt", (double)time(NULL));
cJSON* global_llm = cJSON_GetObjectItemCaseSensitive(user_settings, "global_llm");
if (global_llm && !cJSON_IsObject(global_llm)) {
cJSON_DeleteItemFromObjectCaseSensitive(user_settings, "global_llm");
global_llm = NULL;
}
if (!global_llm) {
cJSON* new_global_llm = cJSON_CreateObject();
if (!new_global_llm) {
cJSON_Delete(user_settings);
if (out_error) *out_error = strdup("allocation failure while preparing global_llm payload");
return -1;
}
cJSON_AddItemToObject(user_settings, "global_llm", new_global_llm);
global_llm = new_global_llm;
}
cJSON_DeleteItemFromObjectCaseSensitive(global_llm, "provider");
cJSON_AddStringToObject(global_llm, "provider", cfg->provider);
cJSON_DeleteItemFromObjectCaseSensitive(global_llm, "api_key");
cJSON_AddStringToObject(global_llm, "api_key", cfg->api_key);
cJSON_DeleteItemFromObjectCaseSensitive(global_llm, "model");
cJSON_AddStringToObject(global_llm, "model", cfg->model);
cJSON_DeleteItemFromObjectCaseSensitive(global_llm, "base_url");
cJSON_AddStringToObject(global_llm, "base_url", cfg->base_url);
cJSON_DeleteItemFromObjectCaseSensitive(global_llm, "max_tokens");
cJSON_AddNumberToObject(global_llm, "max_tokens", cfg->max_tokens);
cJSON_DeleteItemFromObjectCaseSensitive(global_llm, "temperature");
cJSON_AddNumberToObject(global_llm, "temperature", cfg->temperature);
cJSON* didactyl = cJSON_GetObjectItemCaseSensitive(user_settings, "didactyl");
if (didactyl && !cJSON_IsObject(didactyl)) {
cJSON_DeleteItemFromObjectCaseSensitive(user_settings, "didactyl");
didactyl = NULL;
}
if (!didactyl) {
cJSON* new_didactyl = cJSON_CreateObject();
if (!new_didactyl) {
cJSON_Delete(user_settings);
if (out_error) *out_error = strdup("allocation failure while preparing didactyl payload");
return -1;
}
cJSON_AddItemToObject(user_settings, "didactyl", new_didactyl);
didactyl = new_didactyl;
}
cJSON_DeleteItemFromObjectCaseSensitive(didactyl, "max_turns");
cJSON_AddNumberToObject(didactyl,
"max_turns",
(ctx->cfg->tools.max_turns > 0) ? ctx->cfg->tools.max_turns : 40);
cJSON* store_args = cJSON_CreateObject();
if (!store_args) {
cJSON_Delete(user_settings);
if (out_error) *out_error = strdup("allocation failure while building config_store args");
return -1;
}
cJSON_AddStringToObject(store_args, "d_tag", "user-settings");
cJSON_AddItemToObject(store_args, "content", user_settings);
char* store_args_json = cJSON_PrintUnformatted(store_args);
cJSON_Delete(store_args);
if (!store_args_json) {
if (out_error) *out_error = strdup("failed to serialize llm_config payload");
if (out_error) *out_error = strdup("failed to serialize user-settings payload");
return -1;
}
@@ -70,17 +181,17 @@ static int persist_llm_config_nostr(tools_context_t* ctx, const llm_config_t* cf
return -1;
}
cJSON* root = cJSON_Parse(store_result);
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
cJSON* store_root = cJSON_Parse(store_result);
free(store_result);
if (!store_root || !cJSON_IsObject(store_root)) {
cJSON_Delete(store_root);
if (out_error) *out_error = strdup("config_store returned invalid JSON");
free(store_result);
return -1;
}
cJSON* success = cJSON_GetObjectItemCaseSensitive(root, "success");
if (!success || !cJSON_IsBool(success) || !cJSON_IsTrue(success)) {
cJSON* err = cJSON_GetObjectItemCaseSensitive(root, "error");
cJSON* store_success = cJSON_GetObjectItemCaseSensitive(store_root, "success");
if (!store_success || !cJSON_IsBool(store_success) || !cJSON_IsTrue(store_success)) {
cJSON* err = cJSON_GetObjectItemCaseSensitive(store_root, "error");
if (out_error) {
if (err && cJSON_IsString(err) && err->valuestring) {
*out_error = strdup(err->valuestring);
@@ -88,13 +199,11 @@ static int persist_llm_config_nostr(tools_context_t* ctx, const llm_config_t* cf
*out_error = strdup("config_store failed");
}
}
cJSON_Delete(root);
free(store_result);
cJSON_Delete(store_root);
return -1;
}
cJSON_Delete(root);
free(store_result);
cJSON_Delete(store_root);
return 0;
}
@@ -216,7 +325,7 @@ char* execute_model_set(tools_context_t* ctx, const char* args_json) {
ctx->cfg->llm = cfg;
char* persist_error = NULL;
int persisted = (persist_llm_config_nostr(ctx, &cfg, &persist_error) == 0) ? 1 : 0;
int persisted = (persist_global_llm_user_settings_nostr(ctx, &cfg, &persist_error) == 0) ? 1 : 0;
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;

View File

@@ -11,6 +11,7 @@
#include "../debug.h"
#include "../nostr_handler.h"
#include "../trigger_manager.h"
#include "../agent.h"
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
@@ -1769,6 +1770,21 @@ char* execute_skill_edit(tools_context_t* ctx, const char* args_json) {
return json;
}
char* execute_skill_refresh(tools_context_t* ctx, const char* args_json) {
(void)args_json;
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
agent_invalidate_adopted_skills_cache();
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "message", "adopted skills cache invalidated; next context build will reload skills");
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_skill_search(const char* args_json) {
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");

View File

@@ -152,6 +152,9 @@ char* tools_execute_legacy(tools_context_t* ctx, const char* tool_name, const ch
if (strcmp(tool_name, "skill_edit") == 0) {
return execute_skill_edit(ctx, args_json);
}
if (strcmp(tool_name, "skill_refresh") == 0) {
return execute_skill_refresh(ctx, args_json);
}
if (strcmp(tool_name, "trigger_list") == 0) {
return execute_trigger_list(ctx, args_json);
}

View File

@@ -63,6 +63,7 @@ char* execute_skill_set(tools_context_t* ctx, const char* args_json);
char* execute_skill_adopt(tools_context_t* ctx, const char* args_json);
char* execute_skill_remove(tools_context_t* ctx, const char* args_json);
char* execute_skill_edit(tools_context_t* ctx, const char* args_json);
char* execute_skill_refresh(tools_context_t* ctx, const char* args_json);
char* execute_skill_search(const char* args_json);
char* execute_memory_save(tools_context_t* ctx, const char* args_json);

View File

@@ -1077,6 +1077,21 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON_AddItemToObject(t25b, "function", t25b_fn);
cJSON_AddItemToArray(tools, t25b);
cJSON* t25c = cJSON_CreateObject();
cJSON* t25c_fn = cJSON_CreateObject();
cJSON* t25c_params = cJSON_CreateObject();
cJSON* t25c_props = cJSON_CreateObject();
cJSON_AddStringToObject(t25c, "type", "function");
cJSON_AddStringToObject(t25c_fn, "name", "skill_refresh");
cJSON_AddStringToObject(t25c_fn, "description", "Invalidate adopted skills cache so next context build reloads latest skill events from cache/relays");
cJSON_AddStringToObject(t25c_params, "type", "object");
cJSON_AddItemToObject(t25c_params, "properties", t25c_props);
cJSON_AddItemToObject(t25c_fn, "parameters", t25c_params);
cJSON_AddItemToObject(t25c, "function", t25c_fn);
cJSON_AddItemToArray(tools, t25c);
cJSON* t26 = cJSON_CreateObject();
cJSON* t26_fn = cJSON_CreateObject();
cJSON* t26_params = cJSON_CreateObject();