Compare commits

...

5 Commits

25 changed files with 4006 additions and 632 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.29
**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.29Fix self-skill EOSE startup deadlock by adding per-relay timeout fallback in nostr core relay pool
- 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

@@ -0,0 +1,629 @@
# Run Skill Architecture — Tradeoff Analysis
## The Question
Skills today are **adopted** — their content is injected into the agent's context window for every matching trigger. This is "always on" and costs context tokens on every LLM call, even when the skill's capability isn't needed.
What if an agent could **use a skill once and be done with it** — like calling a tool?
Two approaches exist. This document analyzes both.
---
## Approach A: Sub-Invocation (Separate LLM Call)
**Concept:** A `skill_run` tool that spins up an entirely separate LLM execution with the skill's instructions as the system prompt, runs its own tool loop to completion, and returns the result to the calling context.
```mermaid
sequenceDiagram
participant Admin
participant Agent as Agent - Main Context
participant SkillRun as skill_run Executor
participant LLM2 as LLM - Skill Context
Admin->>Agent: Hey, summarize this thread
Agent->>Agent: I need the summarize-thread skill
Agent->>SkillRun: skill_run d_tag=summarize-thread, args=thread_id
SkillRun->>SkillRun: Fetch skill content from Nostr
SkillRun->>SkillRun: Build isolated context window
SkillRun->>LLM2: System: skill instructions, User: args
LLM2->>SkillRun: Tool calls + reasoning
SkillRun->>SkillRun: Execute tool loop to completion
LLM2->>SkillRun: Final text response
SkillRun->>Agent: Return result as tool output
Agent->>Admin: Here is the summary...
```
### How It Works
1. The LLM in the main conversation calls `skill_run` with a `d_tag` (or full `kind:pubkey:d_tag` address) and `args`
2. The runtime fetches the skill event from Nostr (or adopted skills cache)
3. A **new, isolated context window** is built:
- System prompt = skill content (with `{{...}}` template variables resolved)
- User message = the `args` parameter
- Tools = the skill's `requires_tool` tags (or full tool set for own skills)
- LLM config = the skill's `llm`, `temperature`, `max_tokens` tags (or agent defaults)
4. A **separate tool loop** runs (same pattern as [`agent_on_trigger()`](src/agent.c:1696))
5. The final LLM text response is returned as the `skill_run` tool result
6. The calling context continues with that result
### Advantages
| Advantage | Detail |
|-----------|--------|
| **Zero context cost when idle** | The skill's instructions never enter the main context window unless invoked |
| **Isolation** | The skill runs in its own context — it can't see or interfere with the main conversation |
| **Per-skill model** | Each skill can use a different LLM model/provider via its `llm` tag |
| **Sandboxing** | External skills can be restricted to safe tools only |
| **Composability** | Skills can call other skills via nested `skill_run` (with depth limits) |
| **Try-before-adopt** | Run someone else's skill once without permanently adopting it |
| **Clean separation** | The skill is a black box — input in, result out |
### Disadvantages
| Disadvantage | Detail |
|--------------|--------|
| **Extra LLM call** | Every `skill_run` invocation costs at least one additional LLM API call (possibly more if the skill uses tools) |
| **Latency** | The main conversation blocks while the sub-invocation runs its tool loop |
| **No shared context** | The skill doesn't know about the ongoing conversation — it only sees its `args` |
| **Complexity** | Requires managing nested execution contexts, preventing infinite recursion, handling timeouts |
| **Token overhead** | The skill's system prompt is paid for separately — no sharing with the main context |
### Existing Design
This approach is already designed in detail in [`plans/tool_orchestration.md`](plans/tool_orchestration.md) as the `skill_run` tool (Section 2B), including:
- Schema definition
- Execution flow with sandbox for external skills
- Tool safety classification
- `/run` slash command for admin direct invocation
---
## Approach B: Lazy Injection (On-Demand Context Loading)
**Concept:** A `skill_load` tool that fetches a skill's content and injects it into the **current** context window mid-conversation. The LLM then uses those instructions in subsequent reasoning within the same call.
```mermaid
sequenceDiagram
participant Admin
participant Agent as Agent - Single Context
participant LLM as LLM
Admin->>Agent: Hey, summarize this thread
Agent->>LLM: System + User message
LLM->>Agent: I need the summarize-thread skill. Call skill_load.
Agent->>Agent: Fetch skill content, append to messages
Agent->>LLM: ...previous messages + skill content as new system message
LLM->>Agent: Now executing with skill instructions in context...
Agent->>Admin: Here is the summary...
```
### How It Works
1. The LLM calls `skill_load` with a `d_tag`
2. The runtime fetches the skill content
3. The skill content is returned as the tool result (or injected as a new system message)
4. The LLM continues in the **same context window**, now with the skill instructions available
5. No separate LLM call — the skill instructions become part of the ongoing conversation
### Advantages
| Advantage | Detail |
|-----------|--------|
| **No extra LLM call** | The skill instructions are loaded into the current context — no sub-invocation overhead |
| **Shared context** | The skill has full access to the conversation history and can reason about it |
| **Lower latency** | No separate tool loop — the LLM just continues with more information |
| **Simpler implementation** | Just fetch content and return it as a tool result — no nested execution management |
| **Natural flow** | The LLM decides when it needs more instructions and loads them dynamically |
### Disadvantages
| Disadvantage | Detail |
|--------------|--------|
| **Context window cost** | Once loaded, the skill content stays in the context for the rest of the conversation |
| **No isolation** | The skill instructions mix with everything else — potential for instruction conflicts |
| **No per-skill model** | The skill runs on whatever model the current conversation is using |
| **No sandboxing** | The skill's instructions execute with full tool access (same as the main context) |
| **Accumulation** | Loading multiple skills grows the context window — could hit token limits |
| **No clean boundary** | The LLM might partially follow skill instructions or blend them with other context |
### Implementation
This is essentially what `skill_get` already does — it returns skill content. The difference would be:
- `skill_get` returns metadata + content as a JSON blob for the LLM to read
- `skill_load` would inject the content as a system-level instruction that the LLM should follow
In practice, the LLM can already do this today by calling `skill_get` and then reasoning about the returned content. The question is whether a dedicated `skill_load` tool that injects content at the system level would be meaningfully different.
---
## Approach C: Hybrid — Both, With Different Use Cases
The two approaches serve different needs and are not mutually exclusive:
```mermaid
graph TD
A[Agent needs a skill capability] --> B{What kind of need?}
B -->|One-shot task with clear input/output| C[skill_run - Sub-invocation]
B -->|Need skill knowledge in ongoing conversation| D[skill_load - Lazy injection]
B -->|Always need this skill| E[skill_adopt - Permanent adoption]
C --> F[Isolated execution, result returned]
D --> G[Instructions loaded into current context]
E --> H[Instructions in every matching trigger context]
style C fill:#e1f5fe
style D fill:#fff3e0
style E fill:#e8f5e9
```
### When to Use Each
| Scenario | Best Approach | Why |
|----------|--------------|-----|
| Run a friend's skill to try it | `skill_run` | Isolation + sandbox for untrusted code |
| Spell-check this message | `skill_run` | Clear input/output, no conversation context needed |
| Deploy my website | `skill_run` | Task-oriented, uses different tools, benefits from isolation |
| I need to know the formatting rules for Nostr posts | `skill_load` | Reference material for the current conversation |
| Help me write a skill (load the skill-authoring guide) | `skill_load` | The LLM needs the instructions as ongoing context |
| Always respond in a certain personality | `skill_adopt` | Needed in every conversation |
### The Key Distinction
- **`skill_run`** = "Do this task for me and give me the result" (function call semantics)
- **`skill_load`** = "Teach me how to do this so I can do it myself" (knowledge injection)
- **`skill_adopt`** = "I always need to know this" (permanent context)
---
## Relationship to Existing Architecture
### Chain Triggers vs skill_run
[Chain triggers](plans/multi_model_skill_pipelines.md) already provide sequential skill execution — Skill A completes, then Skill B fires. But chains are **pre-configured** (declared in skill tags) and **event-driven** (fire automatically).
`skill_run` is **dynamic** — the LLM decides at runtime which skill to invoke based on the conversation. It's the difference between a cron job and a function call.
### Maturity Levels
The [`tool_orchestration.md`](plans/tool_orchestration.md) plan defines three maturity levels that apply to `skill_run`:
| Level | Execution | LLM? |
|-------|-----------|------|
| `draft` | LLM interprets skill instructions | Yes |
| `guided` | LLM with forced tool_choice | Yes, constrained |
| `hardened` | Deterministic step executor | No |
A `hardened` skill via `skill_run` is essentially a **tool** — no LLM call, just sequential tool execution. This is the ultimate "skill as tool" pattern.
### Recursive skill_run
`skill_run` can call `skill_run` (a skill invokes another skill). This needs a depth limit (like the chain depth limit of 5 in [`trigger_manager_fire_chains()`](src/trigger_manager.c)). Each level gets its own context window and tool loop.
---
## Implementation Considerations
### For skill_run (Sub-Invocation)
The core implementation reuses the existing [`agent_on_trigger()`](src/agent.c:1696) pattern:
1. **New function:** `execute_skill_run()` in [`tool_skill.c`](src/tools/tool_skill.c)
2. **Context building:** Reuse [`build_context_from_triggers()`](src/agent.c) pattern but for a single skill
3. **Tool loop:** Same pattern as `agent_on_trigger()` — call LLM, execute tools, repeat
4. **LLM config:** Save/restore pattern from [`apply_trigger_runtime_to_llm_config()`](src/trigger_manager.c)
5. **Sandbox:** Filter tools array based on skill origin (own vs external)
6. **Depth tracking:** Static or thread-local counter to prevent infinite recursion
7. **Result capture:** Capture the final LLM text response and return it as the tool result
### For skill_load (Lazy Injection)
Minimal new code needed:
1. **New tool:** `skill_load` in [`tool_skill.c`](src/tools/tool_skill.c)
2. **Fetch:** Same as `skill_get` — fetch skill content from cache or Nostr
3. **Return:** Return the skill content as the tool result with a wrapper indicating these are instructions to follow
4. **Alternative:** Inject as a system message in the messages array (requires access to the messages array from within tool execution, which the current architecture doesn't support)
The simpler version (return as tool result) works today with minimal changes. The LLM receives the skill content as a tool response and can reason about it. The more sophisticated version (system message injection) would require architectural changes to how tools interact with the message array.
---
## Approach D: Modes — Skill Packages as Agent Personalities
### The Roo Code Parallel
Roo Code has **modes** — Architect, Code, Ask, Debug — each with its own:
- System prompt / personality
- Set of allowed tools
- File access restrictions
- Model preferences
A mode is essentially a **curated bundle of capabilities** that the agent switches into. You don't load the Architect's planning instructions into the Code mode's context — you *switch modes entirely*.
This maps directly onto Didactyl's skill system.
### Modes as Skill Packages
A **mode** is a named collection of skills that, when activated, replaces the agent's current context window composition. It's a different adoption list — a different set of Layer 1 skills, different tools, potentially a different LLM model.
```mermaid
graph TD
A[Agent receives /mode architect] --> B[Look up mode: architect]
B --> C[Mode defines skills: planning, analysis, design-patterns]
C --> D[Mode defines model: best]
C --> E[Mode defines tools: memory_*, nostr_query, skill_search]
D --> F[Reconfigure agent context for this session]
E --> F
F --> G[Agent now operates as Architect]
G --> H[All subsequent messages use architect context]
```
### Mode Definition as a Skill
A mode is itself a skill — a meta-skill that declares which other skills compose it:
```json
{
"kind": 31123,
"content": "You are operating in Architect mode.\n\nYour role is to plan, design, and analyze before implementation.\n- Break down complex problems into clear steps\n- Create technical specifications\n- Design system architecture\n- Do NOT write implementation code\n\n{{identity}}\n{{planning-guidelines}}",
"tags": [
["d", "mode-architect"],
["description", "Architect mode - planning and design"],
["mode", "architect"],
["llm", "best"],
["temperature", "0.7"],
["requires_skill", "identity"],
["requires_skill", "planning-guidelines"],
["requires_tool", "memory_save"],
["requires_tool", "memory_recall"],
["requires_tool", "nostr_query"],
["requires_tool", "skill_search"],
["requires_tool", "skill_list"]
]
}
```
```json
{
"kind": 31123,
"content": "You are operating in Cheap mode.\n\nYou are a fast, cost-effective assistant.\n- Answer questions directly and concisely\n- Use minimal tokens\n- Do not over-explain\n\n{{identity}}",
"tags": [
["d", "mode-cheap"],
["description", "Cheap mode - fast answers with inexpensive model"],
["mode", "cheap"],
["llm", "openai/gpt-4o-mini, cheap"],
["temperature", "0.3"],
["max_tokens", "500"],
["requires_skill", "identity"],
["requires_tool", "nostr_query"],
["requires_tool", "memory_recall"]
]
}
```
### How Modes Interact with skill_run
Your example — `/skill_run cheap "how many files are in the directory"` — reveals two distinct use cases:
**1. One-shot mode invocation (`skill_run`):**
Run a single task using a mode's configuration, then return to normal. The mode's model, tools, and personality apply for just that one execution.
```
/skill_run mode-cheap "how many files are in the directory"
```
This uses the `skill_run` sub-invocation pattern: spin up an isolated context with the `mode-cheap` skill's instructions, use its `llm` tag (cheap model), execute the tool loop, return the result. The main conversation continues unchanged.
**2. Persistent mode switch (`/mode`):**
Switch the agent's operating mode for all subsequent messages until switched again.
```
/mode architect
> Now operating in Architect mode. Using best model. Planning tools available.
/mode cheap
> Now operating in Cheap mode. Using gpt-4o-mini. Minimal tool set.
/mode default
> Restored default mode.
```
This changes which skills compose the agent's context window for DM conversations. Instead of the normal adoption-list-order composition, the mode's skill set takes over.
### Mode Architecture
```mermaid
graph TD
subgraph Current Architecture
A[Adoption List - kind 10123] --> B[All adopted skills with dm trigger]
B --> C[Context window for every DM]
end
subgraph Mode Architecture
D[Adoption List - kind 10123] --> E{Active mode?}
E -->|No mode| F[Default: all adopted dm-triggered skills]
E -->|Mode active| G[Mode skill + its requires_skill dependencies]
F --> H[Context window]
G --> H
end
subgraph Mode Lifecycle
I[/mode architect] --> J[Set active mode = mode-architect]
J --> K[Subsequent DMs use architect context]
K --> L[/mode default]
L --> M[Clear active mode, restore normal]
end
```
### The Three Invocation Patterns (Updated)
| Pattern | Command | Context | Persists? | Model |
|---------|---------|---------|-----------|-------|
| **Adopted skills** | *(automatic)* | All matching skills in adoption list | Always | Agent default |
| **One-shot skill** | `/skill_run d_tag args` | Isolated sub-invocation | No | Skill's `llm` tag |
| **Mode switch** | `/mode name` | Mode's skill set replaces DM context | Until changed | Mode's `llm` tag |
| **One-shot mode** | `/skill_run mode-name args` | Isolated sub-invocation with mode config | No | Mode's `llm` tag |
### Mode Storage
The active mode is ephemeral runtime state — not persisted to Nostr. It's a session concept:
```c
// In agent.c or a new mode_manager
static char g_active_mode_d_tag[TRIGGER_SKILL_D_TAG_MAX] = {0}; // empty = default mode
void agent_set_mode(const char* mode_d_tag); // NULL or empty = clear mode
const char* agent_get_mode(void);
```
When a mode is active, [`build_context_from_triggers()`](src/agent.c) checks the mode skill first instead of scanning all DM-triggered skills. The mode skill's `requires_skill` tags define which other skills are included (Layer 2).
### Mode Discovery
Modes are just skills with a `["mode", "name"]` tag. They're discoverable the same way:
```
/skill_search mode # find all mode skills
/skill_run mode-architect "plan X" # try a mode one-shot
/mode architect # switch to it persistently
```
Since modes are Nostr events, they're **shareable**. Someone publishes a "code-reviewer" mode with specific skills, model preferences, and tool restrictions. You adopt it and `/mode code-reviewer` to activate it.
### Relationship to Roo Code Modes
| Roo Code | Didactyl Equivalent |
|----------|-------------------|
| Mode definition (`.roomodes`) | Skill event with `["mode", "name"]` tag |
| Mode's system prompt | Skill content (markdown instructions) |
| Mode's allowed tools | `["requires_tool", "..."]` tags |
| Mode's file restrictions | `["requires_tool", "local_file_read"]` presence/absence |
| Mode's model | `["llm", "..."]` tag |
| `/mode architect` | `/mode architect` |
| Mode switching | `/mode name` slash command |
| Custom modes | Create a skill with `["mode", "name"]` tag |
The key difference: Roo Code modes are local config files. Didactyl modes are **Nostr events** — portable, shareable, discoverable, and adoptable across agents.
---
## Modes vs. Multiple Agents
### The Tradeoff
Instead of switching one agent between Architect and Coder modes, you could run two separate agents — one Architect, one Coder. Each has its own nsec, its own skills, its own model. This is already possible today with no code changes.
| Dimension | Modes (one agent) | Multiple Agents (separate binaries) |
|-----------|-------------------|-------------------------------------|
| **Resource cost** | One process, one relay connection, one LLM config | N processes, N relay connections, N LLM configs |
| **Context isolation** | Mode switch replaces context; previous mode's state is lost | Each agent has its own persistent context and memory |
| **Shared memory** | Same agent = same memory store. Architect's notes are available to Coder. | Separate agents = separate memory. Must explicitly share via Nostr events. |
| **Conversation continuity** | Mode switch mid-conversation is seamless — same DM thread | Different agents = different DM threads. Admin must context-switch. |
| **Identity** | One npub. External observers see one entity. | N npubs. Each agent is a distinct Nostr identity. |
| **Specialization depth** | Mode is a context overlay — personality + tools + model. Shallow specialization. | Full agent is deeply specialized — its own memories, its own learned patterns, its own social graph. |
| **Operational complexity** | One binary, one systemd service, one genesis config | N binaries, N services, N configs, N API keys |
| **Failure blast radius** | Agent crashes = all modes unavailable | One agent crashes = others still running |
| **Skill sharing** | All modes share the same adoption list (modes just filter it) | Each agent has its own adoption list |
| **Admin UX** | `/mode architect` — instant switch | DM a different npub — requires client support or manual switching |
### When Modes Win
- **Quick context switching** — "plan this, then code it" in one conversation
- **Shared state** — the Architect's plan is in memory when you switch to Coder mode
- **Low overhead** — one process, one set of relay connections
- **Simple admin UX** — `/mode architect`, `/mode coder`, done
- **Cost control** — `/mode cheap` for quick questions, `/mode deep` for complex analysis
### When Separate Agents Win
- **Deep specialization** — an agent that has spent weeks learning about your codebase vs. one that knows your financial data. These are fundamentally different knowledge bases.
- **Different trust boundaries** — one agent has shell access, another only has Nostr access
- **Different LLM providers** — one on Anthropic, one on OpenAI, one on local Ollama
- **Concurrent operation** — both agents working simultaneously on different tasks
- **Fault isolation** — one agent's crash doesn't affect the other
- **Team simulation** — the admin can DM the Architect to plan, then DM the Coder to implement, and each agent maintains its own persistent context about its domain
### The Hybrid: Modes + Clones
The most powerful pattern combines both:
1. **Start with one agent** with modes for quick context switching
2. **Clone it** (via [`agent_clone`](plans/agent_clone.md)) when you need deep specialization
3. **Specialize the clone** — different skills, different model, different tools
4. **Use modes within each clone** for further flexibility
```
Admin's Agent Fleet:
├── Agent Alpha (generalist)
│ ├── /mode architect — planning
│ ├── /mode coder — implementation
│ └── /mode cheap — quick questions
├── Agent Beta (clone, specialized: infrastructure)
│ ├── /mode deploy — deployment workflows
│ └── /mode monitor — system monitoring
└── Agent Gamma (clone, specialized: content)
├── /mode writer — long-form content
└── /mode social — Nostr social engagement
```
---
## Multi-Agent Didactyl: What Would It Take?
### Current Single-Agent Architecture
The codebase is deeply single-agent. Global state is everywhere:
| Global | File | Purpose |
|--------|------|---------|
| `static didactyl_config_t* g_cfg` | [`agent.c`](src/agent.c:27), [`nostr_handler.c`](src/nostr_handler.c:22) | Agent config including keys |
| `static tools_context_t g_tools_ctx` | [`agent.c`](src/agent.c:28) | Tool execution context |
| `static trigger_manager* g_trigger_manager` | [`agent.c`](src/agent.c:29) | Trigger state |
| `static nostr_relay_pool_t* g_pool` | [`nostr_handler.c`](src/nostr_handler.c:23) | Single relay pool |
| `static llm_config_t g_cfg` | [`llm.c`](src/llm.c:20) | LLM configuration |
| `static char* g_admin_kind0_json` | [`nostr_handler.c`](src/nostr_handler.c:34) | Admin context cache |
| `static char* g_agent_kind0_json` | [`nostr_handler.c`](src/nostr_handler.c:47) | Agent self-context cache |
| `static char* g_memory_plain` | [`tool_memory.c`](src/tools/tool_memory.c:20) | Agent memory |
| 20+ `pthread_mutex_t` globals | Various | Thread safety for all the above |
Every module assumes there is exactly one agent identity, one relay pool, one LLM config, one admin, one set of keys. The keys are used directly via `g_cfg->keys.private_key` in ~40 places across [`nostr_handler.c`](src/nostr_handler.c) for signing, encrypting, and decrypting.
### Three Paths to Multi-Agent
#### Path 1: Multiple Processes (Current — No Code Changes)
Run N separate Didactyl binaries, each with its own genesis config and nsec. This is what [`DECENTRALIZED_DIDACTYL.md`](plans/DECENTRALIZED_DIDACTYL.md) describes.
**Pros:** Works today. Zero code changes. Full isolation.
**Cons:** N × resource cost. N × operational complexity. No shared state.
```
Server
├── /home/didactyl-architect/ (systemd: didactyl-architect.service)
│ ├── genesis.jsonc (nsec1..., model: best)
│ └── didactyl (binary)
├── /home/didactyl-coder/ (systemd: didactyl-coder.service)
│ ├── genesis.jsonc (nsec2..., model: fast)
│ └── didactyl (binary)
└── /home/didactyl-cheap/ (systemd: didactyl-cheap.service)
├── genesis.jsonc (nsec3..., model: cheap)
└── didactyl (binary)
```
#### Path 2: Multi-Agent Single Process (Major Refactor)
Refactor all global state into an `agent_instance_t` struct. Run multiple agent instances in one process, sharing the relay pool and HTTP server.
**What changes:**
```c
typedef struct agent_instance {
didactyl_config_t config;
tools_context_t tools_ctx;
trigger_manager_t trigger_manager;
llm_config_t llm_config;
// ... all per-agent state
} agent_instance_t;
// Instead of:
static didactyl_config_t* g_cfg;
// Becomes:
agent_instance_t* agents;
int agent_count;
```
**Scope of refactor:**
- Every function that touches `g_cfg`, `g_tools_ctx`, `g_trigger_manager`, or any global state needs an `agent_instance_t*` parameter
- [`nostr_handler.c`](src/nostr_handler.c) — the largest file (~4500 lines) — uses `g_cfg->keys` in ~40 places. Every signing, encryption, and subscription call needs to know which agent's keys to use.
- [`llm.c`](src/llm.c) — the global `g_cfg` LLM config needs to become per-instance
- [`agent.c`](src/agent.c) — all context building, message handling, tool loops need per-instance state
- DM routing — incoming DMs need to be dispatched to the correct agent based on the `#p` tag
- The HTTP API needs agent-scoped endpoints (`/api/agents/:id/prompt`, etc.)
**Estimated scope:** Touch every `.c` file. ~2000-3000 lines of refactoring. High risk of regressions.
**Pros:** Shared relay pool (fewer connections). Shared HTTP server. Single process to manage.
**Cons:** Massive refactor. High regression risk. Shared-process failure mode (one agent's crash kills all).
#### Path 3: Multi-Agent via Modes (Minimal Code — Recommended First Step)
Modes give you 80% of the multi-agent benefit with 5% of the effort:
- **Different personality per mode** — mode skill content
- **Different model per mode** — mode's `llm` tag
- **Different tools per mode** — mode's `requires_tool` tags
- **Different temperature per mode** — mode's `temperature` tag
- **Instant switching** — `/mode architect`
- **Shared memory** — same agent, same memory store
- **Shared identity** — same npub, same social graph
What you lose vs. true multi-agent:
- No concurrent operation (one mode at a time)
- No independent persistent context per mode
- No fault isolation between modes
**This is the Roo Code model** — and it works extremely well for the single-admin use case.
### Recommendation
```mermaid
graph TD
A[Start here] --> B[Implement modes - skill packages]
B --> C{Need concurrent agents?}
C -->|No| D[Modes are sufficient]
C -->|Yes| E{How many?}
E -->|2-3| F[Multiple processes - Path 1]
E -->|Many| G{Shared resources matter?}
G -->|No| F
G -->|Yes| H[Multi-agent refactor - Path 2]
style B fill:#e8f5e9
style D fill:#e8f5e9
style F fill:#fff3e0
style H fill:#ffebee
```
1. **Now:** Implement modes (skill packages). This covers the "Architect vs Coder" use case with minimal code.
2. **When needed:** Run multiple Didactyl processes for true concurrent agents. Already works, just operational overhead.
3. **If justified:** Multi-agent single-process refactor. Only if you're running 5+ agents on one server and the resource overhead matters.
---
## Implementation Layers
The features build on each other in this order:
### Layer 1: `skill_run` (Sub-Invocation)
Already designed in [`tool_orchestration.md`](plans/tool_orchestration.md). This is the foundation — an isolated LLM execution of any skill. Enables one-shot skill usage, external skill testing, and sandboxing.
**Enables:** `/skill_run cheap "how many files are in the directory"`
### Layer 2: Slash Commands
Also in [`tool_orchestration.md`](plans/tool_orchestration.md). Direct tool execution via `/tool_name args`. Required for `/mode` and `/skill_run` as admin commands.
**Enables:** `/skill_run`, `/mode`, `/help`
### Layer 3: Modes
Mode skills with `["mode", "name"]` tags. `/mode` slash command to switch. Runtime tracks active mode and adjusts context composition.
**Enables:** `/mode architect`, `/mode cheap`, persistent mode switching
### Layer 4: Hardened Skills
Deterministic step executor for skills that don't need LLM reasoning. A mode could include hardened skills as tools.
**Enables:** Skills that execute as pure tool sequences — zero LLM cost
---
## Recommendation
Implement in this order:
1. **`skill_run`** (sub-invocation) — the foundation. Already designed. Enables one-shot skill execution with isolated context, per-skill model, and sandboxing.
2. **Slash commands** (`/skill_run`, `/help`) — admin direct invocation. Required for the mode UX.
3. **Modes** (`/mode name`) — skill packages that reconfigure the agent's context. Builds on `skill_run` for one-shot mode usage and slash commands for persistent switching.
4. **Hardened skill execution** — deterministic tool sequences. The ultimate "skill as tool" pattern with zero LLM cost.

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.");
@@ -1426,6 +1152,28 @@ static char* resolve_skill_references_local(const char* input) {
return prompt_template_resolve_inline_variables(input ? input : "", &g_tools_ctx);
}
static char* strip_system_role_prefix_local(const char* input) {
const char* s = input ? input : "";
if (strncmp(s, "system:", 7) != 0) {
return strdup(s);
}
const char* p = s + 7;
while (*p == ' ' || *p == '\t') {
p++;
}
if (*p == '\r') {
p++;
}
if (*p == '\n') {
p++;
}
return strdup(p);
}
static char* build_context_from_triggers(trigger_type_t trigger_type,
const char* trigger_filter,
cJSON* trigger_event,
@@ -1501,9 +1249,11 @@ static char* build_context_from_triggers(trigger_type_t trigger_type,
for (int i = 0; i < matched_skill_count; i++) {
char* expanded = resolve_skill_references_local(matched_skill_contents[i] ? matched_skill_contents[i] : "");
const char* skill_text = expanded ? expanded : (matched_skill_contents[i] ? matched_skill_contents[i] : "");
char* normalized = strip_system_role_prefix_local(skill_text);
const char* normalized_text = normalized ? normalized : skill_text;
char* bumped = context_bump_headings(skill_text);
const char* final_text = bumped ? bumped : skill_text;
char* bumped = context_bump_headings(normalized_text);
const char* final_text = bumped ? bumped : normalized_text;
size_t need = strlen("\n\n---\n\n") + strlen(final_text) + 1U;
if (used + need >= cap) {
@@ -1512,6 +1262,7 @@ static char* build_context_from_triggers(trigger_type_t trigger_type,
char* grown = (char*)realloc(out, next);
if (!grown) {
free(bumped);
free(normalized);
free(expanded);
for (int j = i; j < matched_skill_count; j++) {
free(matched_skill_contents[j]);
@@ -1534,6 +1285,7 @@ static char* build_context_from_triggers(trigger_type_t trigger_type,
matched++;
free(bumped);
free(normalized);
free(expanded);
free(matched_skill_contents[i]);
}
@@ -1579,6 +1331,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 +1805,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 +1821,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 +1920,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 +1991,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 +2013,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 +2030,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 +2060,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 +2081,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 +2090,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 +2097,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 +2104,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 +2157,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 +2188,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 +2226,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"
@@ -682,7 +678,7 @@ static int fetch_self_config_plaintext(const didactyl_config_t* cfg, const char*
cJSON_AddItemToObject(filter, "authors", authors);
cJSON_AddItemToArray(d_vals, cJSON_CreateString(d_tag));
cJSON_AddItemToObject(filter, "#d", d_vals);
cJSON_AddNumberToObject(filter, "limit", 1);
cJSON_AddNumberToObject(filter, "limit", 20);
char* events_json = nostr_handler_query_json(filter, 4000);
cJSON_Delete(filter);
@@ -697,8 +693,27 @@ static int fetch_self_config_plaintext(const didactyl_config_t* cfg, const char*
return -1;
}
cJSON* ev = cJSON_GetArrayItem(arr, 0);
cJSON* content = ev ? cJSON_GetObjectItemCaseSensitive(ev, "content") : NULL;
cJSON* newest_ev = NULL;
long newest_created_at = -1;
int n = cJSON_GetArraySize(arr);
for (int i = 0; i < n; i++) {
cJSON* ev = cJSON_GetArrayItem(arr, i);
if (!ev || !cJSON_IsObject(ev)) {
continue;
}
cJSON* content = cJSON_GetObjectItemCaseSensitive(ev, "content");
if (!content || !cJSON_IsString(content) || !content->valuestring || content->valuestring[0] == '\0') {
continue;
}
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
long ts = (created_at && cJSON_IsNumber(created_at)) ? (long)created_at->valuedouble : 0;
if (!newest_ev || ts > newest_created_at) {
newest_ev = ev;
newest_created_at = ts;
}
}
cJSON* content = newest_ev ? cJSON_GetObjectItemCaseSensitive(newest_ev, "content") : NULL;
if (!content || !cJSON_IsString(content) || !content->valuestring || content->valuestring[0] == '\0') {
cJSON_Delete(arr);
return -1;
@@ -726,7 +741,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 +750,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 +793,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 +861,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 +908,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 +936,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 +999,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 +1061,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 +1204,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 +1223,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 +1265,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 29
#define DIDACTYL_VERSION "v0.2.29"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"

View File

@@ -14,6 +14,20 @@
#include "trigger_manager.h"
#include "nostr_block_list.h"
#include "cashu_wallet.h"
#include "agent.h"
/*
* Older nostr_core_lib versions only expose:
* - NOSTR_POOL_EOSE_MOST_RECENT
* - NOSTR_POOL_EOSE_FIRST
* Newer versions may add NOSTR_POOL_EOSE_ANY.
*
* Provide a compatibility alias so code using NOSTR_POOL_EOSE_ANY
* still compiles against older libraries.
*/
#ifndef NOSTR_POOL_EOSE_ANY
#define NOSTR_POOL_EOSE_ANY NOSTR_POOL_EOSE_FIRST
#endif
#define NIP17_MAX_RELAYS 32
#define NIP17_MAX_GIFT_WRAPS 8
@@ -1028,6 +1042,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 +2437,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 +2459,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) {
@@ -2481,7 +2782,7 @@ int nostr_handler_subscribe_self_skills(void) {
NULL,
0,
0,
NOSTR_POOL_EOSE_FIRST,
NOSTR_POOL_EOSE_ANY,
12,
14,
1) != 0) {
@@ -2921,19 +3222,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 +3284,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

@@ -100,7 +100,7 @@ static int query_config_ciphertext_local(tools_context_t* ctx, const char* d_tag
cJSON_AddItemToArray(d_vals, cJSON_CreateString(d_tag));
cJSON_AddItemToObject(filter, "#d", d_vals);
cJSON_AddNumberToObject(filter, "limit", 1);
cJSON_AddNumberToObject(filter, "limit", 20);
char* events_json = nostr_handler_query_json(filter, 4000);
cJSON_Delete(filter);
@@ -116,8 +116,27 @@ static int query_config_ciphertext_local(tools_context_t* ctx, const char* d_tag
return (*out_ciphertext != NULL) ? 0 : -1;
}
cJSON* ev = cJSON_GetArrayItem(arr, 0);
cJSON* content = ev ? cJSON_GetObjectItemCaseSensitive(ev, "content") : NULL;
cJSON* newest_ev = NULL;
long newest_created_at = -1;
int n = cJSON_GetArraySize(arr);
for (int i = 0; i < n; i++) {
cJSON* ev = cJSON_GetArrayItem(arr, i);
if (!ev || !cJSON_IsObject(ev)) {
continue;
}
cJSON* content = cJSON_GetObjectItemCaseSensitive(ev, "content");
if (!content || !cJSON_IsString(content) || !content->valuestring || content->valuestring[0] == '\0') {
continue;
}
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
long ts = (created_at && cJSON_IsNumber(created_at)) ? (long)created_at->valuedouble : 0;
if (!newest_ev || ts > newest_created_at) {
newest_ev = ev;
newest_created_at = ts;
}
}
cJSON* content = newest_ev ? cJSON_GetObjectItemCaseSensitive(newest_ev, "content") : NULL;
const char* cipher = (content && cJSON_IsString(content) && content->valuestring) ? content->valuestring : "";
*out_ciphertext = strdup(cipher);

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();