Files
didactyl/plans/global_llm_alignment.md

12 KiB

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.


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 and §8:

Canonical Schema — global_llm namespace

{
  "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

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

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.

"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 to match.

No C struct changes neededllm_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.cpersist_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.cpersist_runtime_config_to_nostr()

Same flat fields. No change needed.

Phase 3: Didactyl C-side — recall from admin d:user-settings

File: src/main.crecover_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.capply_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:

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

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

Current behavior:

  • fetchNostrLlmConfig() 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(), 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():
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();
}
  1. Update selectLlmProvider() and toggleCurrentModelFavorite() to also patch centralized settings:
// 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
  }
});
  1. 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

The mergeAiConfigFromSettings() 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.