# 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
global_llm namespace] end subgraph Agent Pubkey Events AL[d:llm_config
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.