From 6ad668b6936f7abf68f7dbb9d047bcccb3be8c05 Mon Sep 17 00:00:00 2001 From: Didactyl User Date: Sun, 5 Apr 2026 20:43:32 -0400 Subject: [PATCH] v0.2.25 - Fix private skill interoperability: NIP-44 encode/decode for kind 31124 with runtime tag normalization --- .skills-edit-check.mjs | 1528 ++++++++++++++++++++++++++ README.md | 4 +- docs/GENESIS.md | 40 +- genesis.jsonc.example | 20 +- plans/global_llm_alignment.md | 285 +++++ plans/multi_model_skill_pipelines.md | 283 +++++ src/config.c | 208 +++- src/config.h | 8 + src/main.c | 185 ++-- src/main.h | 4 +- src/nostr_handler.c | 315 +++++- src/setup_wizard.c | 130 ++- src/tools/tool_model.c | 148 ++- 13 files changed, 2904 insertions(+), 254 deletions(-) create mode 100644 .skills-edit-check.mjs create mode 100644 plans/global_llm_alignment.md create mode 100644 plans/multi_model_skill_pipelines.md diff --git a/.skills-edit-check.mjs b/.skills-edit-check.mjs new file mode 100644 index 0000000..39db5b3 --- /dev/null +++ b/.skills-edit-check.mjs @@ -0,0 +1,1528 @@ + + import { + initNDKPage, + getPubkey, + injectHeaderAvatar, + subscribe, + publishEvent, + disconnect, + getVersion, + updateVersionDisplay, + getUserSettings, + patchUserSettings, + onUserSettings + } from './js/init-ndk.mjs'; + import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs"; + import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs'; + import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs'; + import { + initAiSectionWithLocalConfig, + getDefaultAiConfig, + loadAiConfigLocal, + saveAiConfigLocal, + getProviderEntries, + mergeAiConfigFromSettings, + normalizeAiConfig, + normalizeBaseUrl + } from './js/ai-ui.mjs'; + + const DIDACTYL_TOOLS = [ + ['nostr_post', 'Publish a Nostr event to connected relays'], + ['nostr_delete', 'Request deletion of one or more previously published events'], + ['nostr_react', 'React to a Nostr event with like/dislike/emoji'], + ['nostr_query', 'Query events from relays using a Nostr filter'], + ['nostr_my_events', 'Query recent events authored by this agent'], + ['nostr_dm_send', 'Send a NIP-04 encrypted DM'], + ['nostr_dm_send_nip17', 'Send a private DM using NIP-17 gift wrap protocol'], + ['nostr_profile_get', 'Look up a Nostr profile by pubkey'], + ['nostr_nip05_lookup', 'Look up or verify a NIP-05 identifier'], + ['nostr_encode', 'Encode a Nostr entity into nostr: URI'], + ['nostr_decode', 'Decode a Nostr bech32/nostr: URI'], + ['nostr_relay_status', 'Get connection status and statistics for all relays'], + ['nostr_relay_info', 'Fetch NIP-11 relay information document'], + ['nostr_subscription_status', 'List currently managed runtime Nostr subscriptions'], + ['nostr_subscription_set', 'Update one managed runtime subscription by name'], + ['nostr_encrypt', 'Encrypt plaintext using NIP-44 for a recipient'], + ['nostr_decrypt', 'Decrypt NIP-44 ciphertext from a sender'], + ['nostr_list_manage', 'Add/remove tag tuples in replaceable list events'], + ['nostr_block_list', 'View blocked pubkeys, event IDs, or hashtags'], + ['nostr_block_edit', 'Add or remove blocked tuples in kind-10000 block list'], + ['nostr_pubkey', 'Return this agent pubkey in hex format'], + ['nostr_npub', 'Return this agent pubkey encoded as npub'], + ['my_pubkey', 'Alias for nostr_pubkey'], + ['my_npub', 'Alias for nostr_npub'], + ['agent_identity', 'Build agent identity context block'], + ['admin_identity', 'Build admin identity context block'], + ['nostr_admin_profile', 'Build admin profile context block'], + ['nostr_admin_contacts', 'Build admin contacts context block'], + ['nostr_admin_relays', 'Build admin relay context block'], + ['nostr_admin_notes', 'Build admin notes context block'], + ['nostr_agent_profile', 'Build agent profile context block'], + ['nostr_agent_contacts', 'Build agent contacts context block'], + ['nostr_agent_relays', 'Build agent relay context block'], + ['nostr_agent_notes', 'Build agent notes context block'], + ['my_kind0_profile', 'Alias for nostr_agent_profile'], + ['my_contacts', 'Alias for nostr_agent_contacts'], + ['my_relays', 'Alias for nostr_agent_relays'], + ['my_notes', 'Alias for nostr_agent_notes'], + ['skill_create', 'Create or update a skill definition'], + ['skill_edit', 'Edit an existing self skill by d tag'], + ['skill_list', 'List available skills discovered online'], + ['skill_adopt', 'Adopt a skill by adding its address to kind 10123'], + ['skill_remove', 'Remove a skill address from kind 10123'], + ['skill_search', 'Search public skills by query/author'], + ['trigger_list', 'List active triggered skills and runtime status'], + ['task_list', 'Build current task list context block'], + ['task_manage', 'Manage short-term task memory'], + ['memory_save', 'Prepend a new entry to encrypted agent memory'], + ['memory_recall', 'Recall encrypted agent memory'], + ['model_get', 'Get current active LLM runtime configuration'], + ['model_set', 'Update active LLM configuration'], + ['model_list', 'List available model IDs'], + ['config_store', 'Encrypt and publish agent config'], + ['config_recall', 'Fetch and decrypt agent config'], + ['agent_version', 'Return current Didactyl version'], + ['local_http_fetch', 'Fetch HTTP(S) resources'], + ['local_shell_exec', 'Execute a shell command'], + ['local_file_read', 'Read a local file as text'], + ['local_file_write', 'Write text content to a local file'], + ['tool_list', 'List available tools with schemas'], + ['cashu_wallet_balance', 'Return wallet balances by mint and unit'], + ['cashu_wallet_info', 'Fetch mint info'], + ['cashu_wallet_mint_quote', 'Request a mint quote'], + ['cashu_wallet_mint_check', 'Check whether a mint quote is paid/issued'], + ['cashu_wallet_mint_claim', 'Claim minted proofs'], + ['cashu_wallet_melt_quote', 'Request a melt quote'], + ['cashu_wallet_melt_pay', 'Pay a melt quote'], + ['cashu_wallet_check_proofs', 'Ask mint for current proof states'], + ['cashu_wallet_receive_token', 'Receive an ecash token string'], + ['cashu_wallet_send_token', 'Create an outbound ecash token'], + ['cashu_wallet_mints_set', 'Set wallet/public mints'], + ['blossom_upload', 'Upload a local file to a Blossom server'], + ['blossom_download', 'Download a Blossom blob to local path'], + ['blossom_head', 'Fetch Blossom blob metadata by SHA-256'], + ['blossom_delete', 'Delete a Blossom blob by SHA-256'], + ['blossom_list', 'List Blossom blobs for a pubkey'], + ['nostr_post_readme', 'Publish README.md as kind 30023'], + ['nostr_file_md_to_longform_post', 'Read markdown and publish as kind 30023'] + ].map(([name, description]) => ({ name, description })); + + const TOOL_NAME_SET = new Set(DIDACTYL_TOOLS.map(t => t.name)); + + const versionInfo = await getVersion(); + const VERSION = versionInfo.VERSION; + console.log(`[skills-edit.html ${VERSION}] Loading...`); + + const AI_STORAGE_KEY = 'ai_chat_openai_config_v1'; + + let updateIntervalId = null; + let currentPubkey = null; + let isAuthenticated = false; + let authMode = 'required'; + let authedPageInitialized = false; + let relayActivityListenersBound = false; + + let hamburgerInstance = null; + let isNavOpen = false; + let logoutHamburger = null; + let themeToggleHamburger = null; + let isDarkMode = false; + let pageSettings = {}; + let unsubscribeUserSettings = null; + + let activeTab = 'my'; + let selectedSkillAddress = null; + let skillsByAddress = new Map(); + let mySkillSub = null; + let mySkillSubId = null; + let publicSkillSub = null; + let publicSkillSubId = null; + let adoptionSub = null; + let adoptionSubId = null; + let publicLoaded = false; + let adoptedAddresses = []; + let adoptionEventId = null; + + let requiresTools = []; + let optionalTools = []; + let requiresSkills = []; + + let aiConfig = loadAiConfigLocal(getDefaultAiConfig(), AI_STORAGE_KEY); + let fetchedModels = []; + + const divBody = document.getElementById('divBody'); + const divSideNav = document.getElementById('divSideNav'); + const divFooterCenter = document.getElementById('divFooterCenter'); + const divFooterRight = document.getElementById('divFooterRight'); + + const inpSkillSearch = document.getElementById('inpSkillSearch'); + const btnTabMy = document.getElementById('btnTabMy'); + const btnTabPublic = document.getElementById('btnTabPublic'); + const btnTabAdopted = document.getElementById('btnTabAdopted'); + const btnRefreshSkills = document.getElementById('btnRefreshSkills'); + const btnNewSkill = document.getElementById('btnNewSkill'); + const divSkillsList = document.getElementById('divSkillsList'); + const divAdoptedList = document.getElementById('divAdoptedList'); + + const selSkillKind = document.getElementById('selSkillKind'); + const inpSkillSlug = document.getElementById('inpSkillSlug'); + const inpSkillDescription = document.getElementById('inpSkillDescription'); + const selSkillTrigger = document.getElementById('selSkillTrigger'); + const inpSkillFilter = document.getElementById('inpSkillFilter'); + const inpSkillLlm = document.getElementById('inpSkillLlm'); + const inpSkillTemperature = document.getElementById('inpSkillTemperature'); + const inpSkillMaxTokens = document.getElementById('inpSkillMaxTokens'); + const inpSkillSeed = document.getElementById('inpSkillSeed'); + const inpRequiresSkill = document.getElementById('inpRequiresSkill'); + const btnAddRequiresSkill = document.getElementById('btnAddRequiresSkill'); + + const preSkillHighlight = document.getElementById('preSkillHighlight'); + const taSkillContent = document.getElementById('taSkillContent'); + const btnSavePublic = document.getElementById('btnSavePublic'); + const btnSavePrivate = document.getElementById('btnSavePrivate'); + const btnCopyFork = document.getElementById('btnCopyFork'); + const btnDeleteSkill = document.getElementById('btnDeleteSkill'); + const btnAdoptCurrent = document.getElementById('btnAdoptCurrent'); + const btnRemoveAdoptCurrent = document.getElementById('btnRemoveAdoptCurrent'); + const btnPublishAdoption = document.getElementById('btnPublishAdoption'); + const divEditorStatus = document.getElementById('divEditorStatus'); + + const inpToolSearch = document.getElementById('inpToolSearch'); + const divToolsList = document.getElementById('divToolsList'); + const divTriggerFilterHelper = document.getElementById('divTriggerFilterHelper'); + + const selLlmProvider = document.getElementById('selLlmProvider'); + const inpLlmModelFilter = document.getElementById('inpLlmModelFilter'); + const selLlmModel = document.getElementById('selLlmModel'); + const btnLlmFavorite = document.getElementById('btnLlmFavorite'); + const btnLlmApply = document.getElementById('btnLlmApply'); + const divLlmHelperStatus = document.getElementById('divLlmHelperStatus'); + + const CRON_PRESETS = [ + ['Every 5 min', '*/5 * * * *'], + ['Hourly (:00)', '0 * * * *'], + ['Daily noon', '0 12 * * *'], + ['Daily midnight', '0 0 * * *'], + ['Weekdays 9am', '0 9 * * 1-5'], + ['Mondays 9am', '0 9 * * 1'] + ]; + + const NOSTR_FILTER_PRESETS = [ + ['Mentions admin (kind 1)', '{"#p":[""],"kinds":[1]}'], + ['Notes only (kind 1, limit 20)', '{"kinds":[1],"limit":20}'], + ['Profile updates (kind 0)', '{"kinds":[0]}'] + ]; + + const WEBHOOK_FILTER_PRESETS = [ + ['Match all', '{}'], + ['POST only', '{"method":"POST"}'], + ['Path /webhook', '{"path":"/webhook"}'] + ]; + + function setStatus(msg) { + divEditorStatus.textContent = String(msg || ''); + } + + function setLlmHelperStatus(msg) { + if (divLlmHelperStatus) divLlmHelperStatus.textContent = String(msg || ''); + } + + function shortPubkey(pubkey) { + const p = String(pubkey || ''); + if (p.length < 14) return p; + return `${p.slice(0, 6)}...${p.slice(-4)}`; + } + + function normalizeSlug(raw) { + return String(raw || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9-_]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64); + } + + function escapeHtml(s) { + const span = document.createElement('span'); + span.textContent = String(s || ''); + return span.innerHTML; + } + + function getTagValue(tags, key) { + const t = (Array.isArray(tags) ? tags : []).find(x => Array.isArray(x) && x[0] === key); + return t && t[1] ? String(t[1]) : ''; + } + + function getTagValues(tags, key) { + return (Array.isArray(tags) ? tags : []) + .filter(x => Array.isArray(x) && x[0] === key && typeof x[1] === 'string') + .map(x => String(x[1])); + } + + function getLlmProviderEntries() { + return getProviderEntries(aiConfig); + } + + function parseLlmToken(value) { + const raw = String(value || '').trim(); + const first = raw.split(',')[0].trim(); + if (!first) return { provider: '', model: '' }; + const slash = first.indexOf('/'); + if (slash > 0 && slash < first.length - 1) { + return { + provider: first.slice(0, slash).trim(), + model: first.slice(slash + 1).trim() + }; + } + return { + provider: String(aiConfig?.provider || '').trim(), + model: first + }; + } + + function getOrderedModels(filterText = '') { + const models = Array.isArray(fetchedModels) ? fetchedModels : []; + const favorites = Array.isArray(aiConfig?.favorites) ? aiConfig.favorites : []; + const q = String(filterText || '').trim().toLowerCase(); + + const filtered = models.filter((m) => { + const id = String(m?.id || '').trim(); + if (!id) return false; + return !q || id.toLowerCase().includes(q); + }); + + const fav = filtered.filter((m) => favorites.includes(String(m.id || ''))); + const other = filtered.filter((m) => !favorites.includes(String(m.id || ''))); + return { fav, other }; + } + + function renderLlmProviderOptions() { + if (!selLlmProvider) return; + const providers = getLlmProviderEntries(); + const current = String(aiConfig?.provider || '').trim(); + + if (!providers.length) { + selLlmProvider.innerHTML = ''; + return; + } + + selLlmProvider.innerHTML = providers + .map((p) => ``) + .join(''); + + if (current) selLlmProvider.value = current; + } + + function renderLlmModelOptions() { + if (!selLlmModel) return; + const current = String(parseLlmToken(inpSkillLlm?.value).model || aiConfig?.model || '').trim(); + const { fav, other } = getOrderedModels(inpLlmModelFilter?.value || ''); + + const rows = []; + for (const m of fav) { + const id = String(m?.id || '').trim(); + rows.push(``); + } + for (const m of other) { + const id = String(m?.id || '').trim(); + rows.push(``); + } + + selLlmModel.innerHTML = rows.join('') || ''; + if (current) selLlmModel.value = current; + + const selected = String(selLlmModel.value || '').trim(); + const isFav = selected && Array.isArray(aiConfig?.favorites) && aiConfig.favorites.includes(selected); + if (btnLlmFavorite) btnLlmFavorite.textContent = isFav ? '★ Favorited' : '☆ Favorite'; + } + + function selectLlmProvider(providerName) { + const name = String(providerName || '').trim(); + if (!name) return; + const providers = getLlmProviderEntries(); + const selected = providers.find((p) => p.name === name); + if (!selected) return; + + aiConfig = { + ...aiConfig, + provider: selected.name, + base_url: selected.url, + api_key: selected.api_key, + credit_id: selected.credit_id, + model: selected.model || aiConfig.model, + max_tokens: Number.isFinite(selected.max_tokens) && selected.max_tokens > 0 ? Math.floor(selected.max_tokens) : aiConfig.max_tokens, + temperature: Number.isFinite(selected.temperature) ? selected.temperature : aiConfig.temperature + }; + aiConfig = saveAiConfigLocal(aiConfig, AI_STORAGE_KEY); + patchUserSettings({ global_llm: normalizeAiConfig(aiConfig) }).catch((error) => { + console.warn('[skills-edit.html] failed to persist provider to user-settings:', error); + }); + renderLlmProviderOptions(); + fetchLlmModels(); + } + + async function fetchLlmModels() { + const baseUrl = normalizeBaseUrl(aiConfig?.base_url || ''); + const apiKey = String(aiConfig?.api_key || '').trim(); + + if (!baseUrl) { + fetchedModels = []; + renderLlmModelOptions(); + setLlmHelperStatus('No provider base URL configured in AI settings.'); + return; + } + if (!apiKey) { + fetchedModels = []; + renderLlmModelOptions(); + setLlmHelperStatus('No API key configured for selected provider.'); + return; + } + + try { + setLlmHelperStatus('Loading models...'); + const response = await fetch(`${baseUrl}/v1/models`, { + headers: { Authorization: `Bearer ${apiKey}` } + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const seen = new Set(); + fetchedModels = (Array.isArray(data?.data) ? data.data : []) + .map((m) => ({ id: String(m?.id || '').trim() })) + .filter((m) => m.id && !seen.has(m.id) && seen.add(m.id)) + .sort((a, b) => a.id.localeCompare(b.id)); + renderLlmModelOptions(); + setLlmHelperStatus(`Loaded ${fetchedModels.length} models from ${baseUrl}.`); + } catch (error) { + fetchedModels = []; + renderLlmModelOptions(); + setLlmHelperStatus(`Model fetch failed: ${String(error?.message || error)}`); + } + } + + function syncLlmHelperFromSkillInput() { + if (!selLlmProvider || !selLlmModel) return; + const parsed = parseLlmToken(inpSkillLlm?.value || ''); + if (parsed.provider && Array.from(selLlmProvider.options).some((o) => o.value === parsed.provider)) { + selLlmProvider.value = parsed.provider; + } + if (parsed.model && Array.from(selLlmModel.options).some((o) => o.value === parsed.model)) { + selLlmModel.value = parsed.model; + } + renderLlmModelOptions(); + } + + function applyLlmSelectionToInput() { + const provider = String(selLlmProvider?.value || aiConfig?.provider || '').trim(); + const model = String(selLlmModel?.value || '').trim(); + if (!provider || !model) { + setStatus('Select provider and model first.'); + return; + } + inpSkillLlm.value = `${provider}/${model}`; + setStatus(`Set llm tag to ${inpSkillLlm.value}`); + syncLlmHelperFromSkillInput(); + } + + function toggleCurrentModelFavorite() { + const model = String(selLlmModel?.value || '').trim(); + if (!model) return; + const favorites = Array.isArray(aiConfig?.favorites) ? [...aiConfig.favorites] : []; + const idx = favorites.indexOf(model); + if (idx >= 0) favorites.splice(idx, 1); + else favorites.push(model); + aiConfig = { ...aiConfig, favorites }; + aiConfig = saveAiConfigLocal(aiConfig, AI_STORAGE_KEY); + patchUserSettings({ global_llm: normalizeAiConfig(aiConfig) }).catch((error) => { + console.warn('[skills-edit.html] failed to persist favorites to user-settings:', error); + }); + renderLlmModelOptions(); + } + + async function initializeLlmHelperFromSettings() { + aiConfig = loadAiConfigLocal(aiConfig || getDefaultAiConfig(), AI_STORAGE_KEY); + + const llmNode = pageSettings?.global_llm || pageSettings?.ai || null; + if (llmNode && typeof llmNode === 'object') { + aiConfig = mergeAiConfigFromSettings(aiConfig, llmNode); + } + + aiConfig = saveAiConfigLocal(aiConfig, AI_STORAGE_KEY); + renderLlmProviderOptions(); + renderLlmModelOptions(); + syncLlmHelperFromSkillInput(); + fetchLlmModels(); + } + + function parseAddress(address) { + const parts = String(address || '').split(':'); + if (parts.length < 3) return null; + const kind = Number(parts[0]); + const pubkey = parts[1] || ''; + const dTag = parts.slice(2).join(':'); + if (!Number.isFinite(kind) || !pubkey || !dTag) return null; + return { kind, pubkey, dTag }; + } + + function makeAddress(kind, pubkey, dTag) { + return `${Number(kind)}:${String(pubkey || '').trim()}:${String(dTag || '').trim()}`; + } + + function isAddressAdopted(address) { + return adoptedAddresses.includes(String(address || '')); + } + + function cronPartsFromFilter(filterRaw) { + const parts = String(filterRaw || '').trim().split(/\s+/); + if (parts.length === 5) return parts; + return ['*', '*', '*', '*', '*']; + } + + function dmFromFromFilter(filterRaw) { + try { + const parsed = JSON.parse(String(filterRaw || '{}')); + const from = String(parsed?.from || 'admin'); + if (['admin', 'wot', 'any'].includes(from)) return from; + } catch (_error) {} + return 'admin'; + } + + function setFilterPlaceholderByTrigger(trigger) { + const t = String(trigger || '').trim(); + if (!t) { + inpSkillFilter.placeholder = '(select a trigger)'; + return; + } + if (t === 'dm') inpSkillFilter.placeholder = '{"from":"admin"}'; + else if (t === 'cron') inpSkillFilter.placeholder = '0 12 * * *'; + else if (t === 'nostr-subscription') inpSkillFilter.placeholder = '{"#p":[""],"kinds":[1]}'; + else if (t === 'webhook') inpSkillFilter.placeholder = '{}'; + else if (t === 'chain') inpSkillFilter.placeholder = 'source-skill-d-tag'; + else inpSkillFilter.placeholder = 'type-specific filter'; + } + + function renderTriggerFilterHelper() { + if (!divTriggerFilterHelper) return; + const trigger = String(selSkillTrigger.value || '').trim(); + setFilterPlaceholderByTrigger(trigger); + + if (!trigger) { + divTriggerFilterHelper.innerHTML = '
Select a trigger to see filter options and presets.
'; + return; + } + + if (trigger === 'dm') { + const currentFrom = dmFromFromFilter(inpSkillFilter.value); + divTriggerFilterHelper.innerHTML = ` +
DM filter helper
+
+ + +
+
Writes JSON like {"from":"admin"}.
+ `; + + const sel = document.getElementById('selDmFilterFrom'); + const btn = document.getElementById('btnApplyDmFilter'); + if (btn && sel) { + btn.addEventListener('click', () => { + inpSkillFilter.value = JSON.stringify({ from: String(sel.value || 'admin') }); + setStatus(`Set dm filter: ${inpSkillFilter.value}`); + }); + } + return; + } + + if (trigger === 'cron') { + const parts = cronPartsFromFilter(inpSkillFilter.value); + divTriggerFilterHelper.innerHTML = ` +
Cron filter helper
+
+ + +
+
+ + + + + + +
+
Format: minute hour day-of-month month day-of-week
+ `; + + const btnPreset = document.getElementById('btnApplyCronPreset'); + const selPreset = document.getElementById('selCronPreset'); + const btnCompose = document.getElementById('btnComposeCron'); + if (btnPreset && selPreset) { + btnPreset.addEventListener('click', () => { + inpSkillFilter.value = String(selPreset.value || '').trim(); + setStatus(`Set cron filter: ${inpSkillFilter.value}`); + renderTriggerFilterHelper(); + }); + } + if (btnCompose) { + btnCompose.addEventListener('click', () => { + const min = String(document.getElementById('inpCronMin')?.value || '*').trim() || '*'; + const hour = String(document.getElementById('inpCronHour')?.value || '*').trim() || '*'; + const dom = String(document.getElementById('inpCronDom')?.value || '*').trim() || '*'; + const mon = String(document.getElementById('inpCronMon')?.value || '*').trim() || '*'; + const dow = String(document.getElementById('inpCronDow')?.value || '*').trim() || '*'; + inpSkillFilter.value = `${min} ${hour} ${dom} ${mon} ${dow}`; + setStatus(`Set cron filter: ${inpSkillFilter.value}`); + }); + } + return; + } + + if (trigger === 'chain') { + const options = Array.from(skillsByAddress.values()) + .filter((s) => s && s.slug) + .sort((a, b) => String(a.slug).localeCompare(String(b.slug))); + + divTriggerFilterHelper.innerHTML = ` +
Chain filter helper
+
+ + +
+
Filter should be the source skill d tag.
+ `; + + const selChain = document.getElementById('selChainSource'); + const btnChain = document.getElementById('btnApplyChain'); + if (selChain && inpSkillFilter.value) selChain.value = String(inpSkillFilter.value || '').trim(); + if (btnChain && selChain) { + btnChain.addEventListener('click', () => { + const v = String(selChain.value || '').trim(); + if (!v) return; + inpSkillFilter.value = v; + setStatus(`Set chain filter: ${inpSkillFilter.value}`); + }); + } + return; + } + + if (trigger === 'nostr-subscription') { + divTriggerFilterHelper.innerHTML = ` +
Nostr-subscription filter helper
+
+ + +
+
Filter is a JSON-encoded Nostr subscription filter.
+ `; + + const sel = document.getElementById('selNostrFilterPreset'); + const btn = document.getElementById('btnApplyNostrFilter'); + if (btn && sel) { + btn.addEventListener('click', () => { + inpSkillFilter.value = String(sel.value || '').trim(); + setStatus('Set nostr-subscription filter preset.'); + }); + } + return; + } + + if (trigger === 'webhook') { + divTriggerFilterHelper.innerHTML = ` +
Webhook filter helper
+
+ + +
+
Use {} to match all requests.
+ `; + + const sel = document.getElementById('selWebhookFilterPreset'); + const btn = document.getElementById('btnApplyWebhookFilter'); + if (btn && sel) { + btn.addEventListener('click', () => { + inpSkillFilter.value = String(sel.value || '{}').trim(); + setStatus(`Set webhook filter: ${inpSkillFilter.value}`); + }); + } + return; + } + + divTriggerFilterHelper.innerHTML = '
No helper available for this trigger.
'; + } + + function toggleTabUI() { + btnTabMy.classList.toggle('active', activeTab === 'my'); + btnTabPublic.classList.toggle('active', activeTab === 'public'); + btnTabAdopted.classList.toggle('active', activeTab === 'adopted'); + } + + function clearEditor() { + selectedSkillAddress = null; + selSkillKind.value = '31123'; + inpSkillSlug.value = ''; + inpSkillDescription.value = ''; + selSkillTrigger.value = ''; + inpSkillFilter.value = ''; + inpSkillLlm.value = ''; + inpSkillTemperature.value = ''; + inpSkillMaxTokens.value = ''; + inpSkillSeed.value = ''; + taSkillContent.value = '## Purpose\nYou are a helpful assistant.\n\nInput:\n{{message}}'; + requiresTools = []; + optionalTools = []; + requiresSkills = []; + renderTagChips(); + renderHighlight(); + renderTools(); + renderTriggerFilterHelper(); + syncLlmHelperFromSkillInput(); + setStatus('New skill ready.'); + btnCopyFork.style.display = 'none'; + } +function renderTagChips() { + // Tag chips panel intentionally removed from UI to save space. + // Keep this as a no-op so existing workflows can continue calling it safely. +} + + + function getVisibleSkills() { + const q = String(inpSkillSearch.value || '').trim().toLowerCase(); + const skills = Array.from(skillsByAddress.values()); + let scoped = []; + + if (activeTab === 'my') { + scoped = skills.filter(s => s.author === currentPubkey); + } else if (activeTab === 'public') { + scoped = skills.filter(s => s.kind === 31123 && s.author !== currentPubkey); + } else { + scoped = adoptedAddresses + .map(addr => skillsByAddress.get(addr) || { + address: addr, + slug: parseAddress(addr)?.dTag || addr, + description: '(not loaded yet)', + author: parseAddress(addr)?.pubkey || '', + kind: parseAddress(addr)?.kind || 31123, + missing: true, + created_at: 0 + }); + } + + if (q) { + scoped = scoped.filter((s) => { + const text = `${s.slug || ''} ${s.description || ''} ${s.author || ''}`.toLowerCase(); + return text.includes(q); + }); + } + + if (activeTab !== 'adopted') { + scoped.sort((a, b) => { + const ta = Number(a.created_at || 0); + const tb = Number(b.created_at || 0); + if (tb !== ta) return tb - ta; + return String(a.slug || '').localeCompare(String(b.slug || '')); + }); + } + + return scoped; + } + + function renderSkillsList() { + toggleTabUI(); + const list = getVisibleSkills(); + if (!list.length) { + divSkillsList.innerHTML = '
No skills in this tab.
'; + return; + } + + divSkillsList.innerHTML = list.map((s) => { + const adopted = isAddressAdopted(s.address); + const active = selectedSkillAddress === s.address ? 'active' : ''; + const author = shortPubkey(s.author); + return ` +
+
+ ${escapeHtml(s.slug || '(missing slug)')} + ${adopted ? '' : ''} +
+
${escapeHtml(s.description || '')}
+
${escapeHtml(author)} · ${escapeHtml(String(s.kind || ''))}${s.missing ? ' · missing' : ''}
+
+ `; + }).join(''); + + Array.from(divSkillsList.querySelectorAll('.skillItem[data-address]')).forEach((el) => { + el.addEventListener('click', () => { + const address = String(el.getAttribute('data-address') || ''); + loadSkillIntoEditor(address); + }); + }); + } + + function renderAdoptedList() { + if (!adoptedAddresses.length) { + divAdoptedList.innerHTML = '
No adopted skills yet.
'; + return; + } + + divAdoptedList.innerHTML = adoptedAddresses.map((addr, i) => { + const s = skillsByAddress.get(addr); + const label = s ? `${s.slug} (${shortPubkey(s.author)})` : (parseAddress(addr)?.dTag || addr); + return ` +
+
${escapeHtml(label)}
+
+ + + +
+
+ `; + }).join(''); + + Array.from(divAdoptedList.querySelectorAll('button[data-act][data-i]')).forEach((btn) => { + btn.addEventListener('click', () => { + const i = Number(btn.getAttribute('data-i')); + const act = String(btn.getAttribute('data-act') || ''); + if (!Number.isFinite(i) || i < 0 || i >= adoptedAddresses.length) return; + if (act === 'remove') { + adoptedAddresses.splice(i, 1); + } else if (act === 'up' && i > 0) { + const tmp = adoptedAddresses[i - 1]; + adoptedAddresses[i - 1] = adoptedAddresses[i]; + adoptedAddresses[i] = tmp; + } else if (act === 'down' && i < adoptedAddresses.length - 1) { + const tmp = adoptedAddresses[i + 1]; + adoptedAddresses[i + 1] = adoptedAddresses[i]; + adoptedAddresses[i] = tmp; + } + renderAdoptedList(); + renderSkillsList(); + }); + }); + } + + function renderTools() { + const q = String(inpToolSearch.value || '').trim().toLowerCase(); + const filtered = DIDACTYL_TOOLS.filter((t) => { + if (!q) return true; + return t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q); + }); + + divToolsList.innerHTML = filtered.map((t) => { + const required = requiresTools.includes(t.name) ? 'required' : ''; + return ` +
+
${escapeHtml(t.name)}
+
${escapeHtml(t.description)}
+
+ `; + }).join(''); + + Array.from(divToolsList.querySelectorAll('.toolItem[data-tool]')).forEach((el) => { + el.addEventListener('dblclick', () => { + const tool = String(el.getAttribute('data-tool') || ''); + if (!tool) return; + if (!requiresTools.includes(tool)) requiresTools.push(tool); + insertAtCursor(taSkillContent, `{{${tool}}}`); + renderTagChips(); + renderTools(); + renderHighlight(); + setStatus(`Added requires_tool and inserted {{${tool}}}`); + }); + }); + } + + function insertAtCursor(textarea, text) { + const start = textarea.selectionStart ?? textarea.value.length; + const end = textarea.selectionEnd ?? textarea.value.length; + const before = textarea.value.slice(0, start); + const after = textarea.value.slice(end); + textarea.value = `${before}${text}${after}`; + const next = start + text.length; + textarea.selectionStart = next; + textarea.selectionEnd = next; + textarea.focus(); + } + + function renderHighlight() { + let html = escapeHtml(taSkillContent.value || ''); + + html = html.replace(/\{\{\s*([a-zA-Z0-9_-]+)\s*\}\}/g, (m, name) => { + if (TOOL_NAME_SET.has(name)) { + return `{{${escapeHtml(name)}}}`; + } + return `{{${escapeHtml(name)}}}`; + }); + + preSkillHighlight.innerHTML = html + '\n'; + } + + function syncEditorScroll() { + preSkillHighlight.scrollTop = taSkillContent.scrollTop; + preSkillHighlight.scrollLeft = taSkillContent.scrollLeft; + } + + function upsertSkill(skill) { + if (!skill || !skill.address) return; + const prev = skillsByAddress.get(skill.address); + if (!prev || Number(skill.created_at || 0) >= Number(prev.created_at || 0)) { + skillsByAddress.set(skill.address, skill); + } + } + + async function parseSkillEvent(evt) { + if (!evt || (evt.kind !== 31123 && evt.kind !== 31124)) return null; + const slug = normalizeSlug(getTagValue(evt.tags, 'd')); + if (!slug) return null; + + let content = String(evt.content || ''); + let tags = Array.isArray(evt.tags) ? evt.tags.map(t => Array.isArray(t) ? [...t] : t) : []; + let decryptError = ''; + + if (evt.kind === 31124) { + try { + if (!window.nostr?.nip44?.decrypt) throw new Error('nip44 decrypt unavailable in signer'); + const plaintext = await window.nostr.nip44.decrypt(String(evt.pubkey || ''), String(evt.content || '')); + const parsed = JSON.parse(String(plaintext || '{}')); + content = String(parsed?.content || ''); + const privateTags = Array.isArray(parsed?.private_tags) ? parsed.private_tags : []; + tags = [['d', slug], ...privateTags.filter(t => Array.isArray(t) && t.length >= 2)]; + } catch (error) { + decryptError = String(error?.message || error || 'decrypt failed'); + } + } + + const address = makeAddress(evt.kind, evt.pubkey, slug); + return { + address, + key: `${address}`, + kind: Number(evt.kind), + slug, + description: getTagValue(tags, 'description') || slug, + trigger: getTagValue(tags, 'trigger'), + filter: getTagValue(tags, 'filter'), + llm: getTagValue(tags, 'llm'), + temperature: getTagValue(tags, 'temperature'), + max_tokens: getTagValue(tags, 'max_tokens'), + seed: getTagValue(tags, 'seed'), + requires_tool: getTagValues(tags, 'requires_tool'), + optional_tool: getTagValues(tags, 'optional_tool'), + requires_skill: getTagValues(tags, 'requires_skill'), + content, + rawTags: tags, + rawEvent: evt, + author: String(evt.pubkey || ''), + created_at: Number(evt.created_at || 0), + decryptError + }; + } + + function loadSkillIntoEditor(address) { + const skill = skillsByAddress.get(address); + selectedSkillAddress = address; + renderSkillsList(); + if (!skill) { + setStatus('Skill record is not loaded yet.'); + return; + } + + selSkillKind.value = String(skill.kind || 31123); + inpSkillSlug.value = skill.slug || ''; + inpSkillDescription.value = skill.description || ''; + selSkillTrigger.value = skill.trigger || ''; + inpSkillFilter.value = skill.filter || ''; + renderTriggerFilterHelper(); + inpSkillLlm.value = skill.llm || ''; + syncLlmHelperFromSkillInput(); + inpSkillTemperature.value = skill.temperature || ''; + inpSkillMaxTokens.value = skill.max_tokens || ''; + inpSkillSeed.value = skill.seed || ''; + taSkillContent.value = String(skill.content || ''); + requiresTools = [...(skill.requires_tool || [])]; + optionalTools = [...(skill.optional_tool || [])]; + requiresSkills = [...(skill.requires_skill || [])]; + + renderTagChips(); + renderTools(); + renderHighlight(); + syncEditorScroll(); + + const isForeignPublic = skill.kind === 31123 && skill.author && currentPubkey && skill.author !== currentPubkey; + btnCopyFork.style.display = isForeignPublic ? '' : 'none'; + + if (skill.decryptError) { + setStatus(`Loaded ${skill.slug} with decrypt warning: ${skill.decryptError}`); + } else { + setStatus(`Loaded ${skill.slug}`); + } + } + + async function handleIncomingSkillEvent(evt) { + const parsed = await parseSkillEvent(evt); + if (!parsed) return; + upsertSkill(parsed); + renderSkillsList(); + renderAdoptedList(); + renderTriggerFilterHelper(); + if (selectedSkillAddress === parsed.address) { + loadSkillIntoEditor(parsed.address); + } + } + + function parseAdoptionEvent(evt) { + if (!evt || evt.kind !== 10123 || evt.pubkey !== currentPubkey) return; + const tags = Array.isArray(evt.tags) ? evt.tags : []; + adoptedAddresses = tags + .filter(t => Array.isArray(t) && t[0] === 'a' && typeof t[1] === 'string') + .map(t => String(t[1])); + adoptionEventId = String(evt.id || ''); + renderAdoptedList(); + renderSkillsList(); + } + + function buildSkillTags() { + const tags = []; + const slug = normalizeSlug(inpSkillSlug.value); + const description = String(inpSkillDescription.value || '').trim(); + const trigger = String(selSkillTrigger.value || '').trim(); + const filter = String(inpSkillFilter.value || '').trim(); + const llm = String(inpSkillLlm.value || '').trim(); + const temperature = String(inpSkillTemperature.value || '').trim(); + const maxTokens = String(inpSkillMaxTokens.value || '').trim(); + const seed = String(inpSkillSeed.value || '').trim(); + + tags.push(['d', slug]); + if (description) tags.push(['description', description]); + if (trigger) tags.push(['trigger', trigger]); + if (filter) tags.push(['filter', filter]); + if (llm) tags.push(['llm', llm]); + if (temperature) tags.push(['temperature', temperature]); + if (maxTokens) tags.push(['max_tokens', maxTokens]); + if (seed) tags.push(['seed', seed]); + + for (const t of requiresTools) tags.push(['requires_tool', t]); + for (const t of optionalTools) tags.push(['optional_tool', t]); + for (const s of requiresSkills) tags.push(['requires_skill', s]); + + return tags; + } + + async function saveSkill(kind) { + const slug = normalizeSlug(inpSkillSlug.value); + if (!slug) return setStatus('Slug is required.'); + const content = String(taSkillContent.value || '').trim(); + if (!content) return setStatus('Skill content is required.'); + const now = Math.floor(Date.now() / 1000); + + try { + if (Number(kind) === 31123) { + const event = { + kind: 31123, + content, + tags: buildSkillTags(), + created_at: now + }; + await publishEvent(event); + setStatus(`Published public skill: ${slug}`); + } else { + if (!window.nostr?.nip44?.encrypt) { + throw new Error('NIP-44 encryption unavailable in signer'); + } + const allTags = buildSkillTags(); + const privateTags = allTags.filter((t) => Array.isArray(t) && t[0] !== 'd'); + const payload = JSON.stringify({ + content, + private_tags: privateTags + }); + const ciphertext = await window.nostr.nip44.encrypt(currentPubkey, payload); + const event = { + kind: 31124, + content: String(ciphertext || ''), + tags: [['d', slug]], + created_at: now + }; + await publishEvent(event); + setStatus(`Published private skill: ${slug}`); + } + + refreshSkills(); + } catch (error) { + setStatus(`Save failed: ${String(error?.message || error)}`); + } + } + + async function deleteSelectedSkill() { + const skill = selectedSkillAddress ? skillsByAddress.get(selectedSkillAddress) : null; + if (!skill) return setStatus('Select a skill first.'); + if (skill.author !== currentPubkey) return setStatus('You can only delete your own skills.'); + if (!confirm(`Delete skill "${skill.slug}"?`)) return; + + const event = { + kind: 5, + content: '', + tags: [['a', skill.address]], + created_at: Math.floor(Date.now() / 1000) + }; + if (skill.rawEvent?.id) event.tags.push(['e', skill.rawEvent.id]); + + try { + await publishEvent(event); + setStatus(`Deletion request published for ${skill.slug}`); + } catch (error) { + setStatus(`Delete failed: ${String(error?.message || error)}`); + } + } + + function currentEditorAddress() { + const kind = Number(selSkillKind.value || 31123); + const slug = normalizeSlug(inpSkillSlug.value); + if (!slug || !currentPubkey) return null; + return makeAddress(kind, currentPubkey, slug); + } + + async function publishAdoptionList() { + try { + const event = { + kind: 10123, + content: '', + tags: adoptedAddresses.map((addr) => ['a', addr]), + created_at: Math.floor(Date.now() / 1000) + }; + if (adoptionEventId) event.tags.push(['e', adoptionEventId]); + await publishEvent(event); + setStatus(`Published adoption list (${adoptedAddresses.length} skills).`); + } catch (error) { + setStatus(`Publish 10123 failed: ${String(error?.message || error)}`); + } + } + + async function adoptCurrentSkill() { + let addr = selectedSkillAddress; + if (!addr) addr = currentEditorAddress(); + if (!addr) return setStatus('Need selected skill or valid slug/kind to adopt.'); + if (!adoptedAddresses.includes(addr)) adoptedAddresses.push(addr); + renderAdoptedList(); + renderSkillsList(); + await publishAdoptionList(); + } + + async function removeCurrentAdoptedSkill() { + let addr = selectedSkillAddress; + if (!addr) addr = currentEditorAddress(); + if (!addr) return setStatus('Need selected skill or valid slug/kind.'); + adoptedAddresses = adoptedAddresses.filter((x) => x !== addr); + renderAdoptedList(); + renderSkillsList(); + await publishAdoptionList(); + } + + function copySelectedSkillToMine() { + const skill = selectedSkillAddress ? skillsByAddress.get(selectedSkillAddress) : null; + if (!skill) return setStatus('Select a public skill to copy.'); + if (skill.kind !== 31123 || skill.author === currentPubkey) return setStatus('Copy is for public skills by other authors.'); + + const nextSlug = normalizeSlug(`${skill.slug}-copy`) || `skill-copy-${Date.now()}`; + selSkillKind.value = '31123'; + inpSkillSlug.value = nextSlug; + inpSkillDescription.value = `${skill.description || skill.slug} (copy)`; + selSkillTrigger.value = skill.trigger || ''; + inpSkillFilter.value = skill.filter || ''; + inpSkillLlm.value = skill.llm || ''; + syncLlmHelperFromSkillInput(); + inpSkillTemperature.value = skill.temperature || ''; + inpSkillMaxTokens.value = skill.max_tokens || ''; + inpSkillSeed.value = skill.seed || ''; + taSkillContent.value = skill.content || ''; + requiresTools = [...(skill.requires_tool || [])]; + optionalTools = [...(skill.optional_tool || [])]; + requiresSkills = [...(skill.requires_skill || [])]; + selectedSkillAddress = null; + renderTagChips(); + renderTools(); + renderHighlight(); + renderTriggerFilterHelper(); + renderSkillsList(); + btnCopyFork.style.display = 'none'; + setStatus(`Copied ${skill.slug} to editor as ${nextSlug}. Save to publish.`); + } + + function clearSub(refName) { + try { + const sub = (refName === 'my') ? mySkillSub : (refName === 'public') ? publicSkillSub : adoptionSub; + if (sub && typeof sub.close === 'function') sub.close(); + } catch (_error) {} + if (refName === 'my') { + mySkillSub = null; + mySkillSubId = null; + } else if (refName === 'public') { + publicSkillSub = null; + publicSkillSubId = null; + } else { + adoptionSub = null; + adoptionSubId = null; + } + } + + function refreshMySkills() { + clearSub('my'); + mySkillSub = subscribe({ kinds: [31123, 31124], authors: [currentPubkey], limit: 400 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' }); + mySkillSubId = mySkillSub?.subId || null; + } + + function refreshPublicSkills() { + clearSub('public'); + publicSkillSub = subscribe({ kinds: [31123], limit: 500 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' }); + publicSkillSubId = publicSkillSub?.subId || null; + publicLoaded = true; + } + + function refreshAdoption() { + clearSub('adoption'); + adoptionSub = subscribe({ kinds: [10123], authors: [currentPubkey], limit: 1 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' }); + adoptionSubId = adoptionSub?.subId || null; + } + + function refreshSkills() { + setStatus('Refreshing skills...'); + refreshMySkills(); + refreshAdoption(); + if (activeTab === 'public' || publicLoaded) refreshPublicSkills(); + } + + function bindSkillEventListeners() { + window.addEventListener('ndkEvent', async (event) => { + const evt = event?.detail; + if (!evt) return; + if (evt.kind === 10123 && evt.pubkey === currentPubkey) { + parseAdoptionEvent(evt); + return; + } + if (evt.kind === 31123 || evt.kind === 31124) { + await handleIncomingSkillEvent(evt); + } + }); + + window.addEventListener('ndkEose', (event) => { + const sid = event?.detail?.subId || null; + if (!sid) return; + if (sid === mySkillSubId || sid === publicSkillSubId || sid === adoptionSubId) { + if (!getVisibleSkills().length) { + setStatus(activeTab === 'public' ? 'No public skills found yet.' : 'No skills found yet.'); + } + } + }); + } + + function bindUi() { + btnTabMy.addEventListener('click', () => { + activeTab = 'my'; + renderSkillsList(); + }); + btnTabPublic.addEventListener('click', () => { + activeTab = 'public'; + if (!publicLoaded) refreshPublicSkills(); + renderSkillsList(); + }); + btnTabAdopted.addEventListener('click', () => { + activeTab = 'adopted'; + renderSkillsList(); + }); + + inpSkillSearch.addEventListener('input', renderSkillsList); + inpToolSearch.addEventListener('input', renderTools); + selSkillTrigger.addEventListener('change', renderTriggerFilterHelper); + selLlmProvider?.addEventListener('change', () => selectLlmProvider(selLlmProvider.value)); + selLlmModel?.addEventListener('change', renderLlmModelOptions); + inpLlmModelFilter?.addEventListener('input', renderLlmModelOptions); + btnLlmFavorite?.addEventListener('click', toggleCurrentModelFavorite); + btnLlmApply?.addEventListener('click', applyLlmSelectionToInput); + + btnRefreshSkills.addEventListener('click', refreshSkills); + btnNewSkill.addEventListener('click', clearEditor); + + taSkillContent.addEventListener('input', renderHighlight); + taSkillContent.addEventListener('scroll', syncEditorScroll); + window.addEventListener('resize', syncEditorScroll); + + btnAddRequiresSkill.addEventListener('click', () => { + const v = normalizeSlug(inpRequiresSkill.value); + if (!v) return; + if (!requiresSkills.includes(v)) requiresSkills.push(v); + inpRequiresSkill.value = ''; + renderTagChips(); + }); + + btnSavePublic.addEventListener('click', () => saveSkill(31123)); + btnSavePrivate.addEventListener('click', () => saveSkill(31124)); + btnDeleteSkill.addEventListener('click', deleteSelectedSkill); + btnCopyFork.addEventListener('click', copySelectedSkillToMine); + btnAdoptCurrent.addEventListener('click', adoptCurrentSkill); + btnRemoveAdoptCurrent.addEventListener('click', removeCurrentAdoptedSkill); + btnPublishAdoption.addEventListener('click', publishAdoptionList); + + btnCopyFork.style.display = 'none'; + renderTriggerFilterHelper(); + syncLlmHelperFromSkillInput(); + } + + function initHamburgerMenu() { + hamburgerInstance = new HamburgerMorphing('#divSvgHam', { + foreground: 'var(--primary-color)', + background: 'var(--secondary-color)', + hover: 'var(--accent-color)' + }); + hamburgerInstance.animateTo('burger'); + } + + function openNav() { + divSideNav.style.zIndex = 3; + divSideNav.style.width = 'clamp(400px, 50vw, 600px)'; + isNavOpen = true; + if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left'); + + if (!logoutHamburger) { + logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', { + size: 24, + foreground: 'var(--primary-color)', + background: 'var(--secondary-color)', + hover: 'var(--accent-color)' + }); + logoutHamburger.animateTo('x'); + } + + if (!themeToggleHamburger) { + themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', { + size: 24, + foreground: 'var(--primary-color)', + background: 'var(--secondary-color)', + hover: 'var(--accent-color)' + }); + const savedTheme = localStorage.getItem('theme'); + isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode'); + themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle'); + } + } + + function closeNav() { + divSideNav.style.width = '0vw'; + divSideNav.style.zIndex = -1; + isNavOpen = false; + if (hamburgerInstance) hamburgerInstance.animateTo('burger'); + } + + function toggleNav() { + if (isNavOpen) closeNav(); + else openNav(); + } + + function hasTargetPubkeyInUrl() { + const params = new URLSearchParams(window.location.search || ''); + return Boolean(String(params.get('npub') || '').trim() || String(params.get('pubkey') || '').trim()); + } + + function resolveAuthModeFromUrl() { + const params = new URLSearchParams(window.location.search || ''); + const explicitAuth = String(params.get('auth') || '').trim().toLowerCase(); + if (['required', 'optional', 'none'].includes(explicitAuth)) return explicitAuth; + if (hasTargetPubkeyInUrl()) return 'optional'; + return 'required'; + } + + function isAuthRequiredError(error) { + const message = String(error?.message || error || '').toLowerCase(); + return message.includes('authentication required'); + } + + async function initializeAuthentication(mode) { + if (mode === 'required') { + await initNDKPage(); + currentPubkey = await getPubkey(); + isAuthenticated = true; + return; + } + if (mode === 'none') { + isAuthenticated = false; + currentPubkey = null; + return; + } + + try { + await initNDKPage(); + currentPubkey = await getPubkey(); + isAuthenticated = true; + } catch (error) { + if (isAuthRequiredError(error)) { + isAuthenticated = false; + currentPubkey = null; + return; + } + throw error; + } + } + + async function UpdateFooter() { + try { + await updateFooterRelayStatus(); + await updateSidenavRelaySection(); + await updateBlossomSection(); + divFooterCenter.innerHTML = ''; + divFooterRight.innerHTML = ''; + } catch (error) { + console.error('[skills-edit.html] footer update error:', error); + } + } + + async function initializeAuthenticatedPageFeatures() { + if (!isAuthenticated || authedPageInitialized) return; + + await injectHeaderAvatar(currentPubkey); + + try { + pageSettings = await getUserSettings(); + } catch (_error) { + pageSettings = {}; + } + + if (!unsubscribeUserSettings) { + unsubscribeUserSettings = onUserSettings((settings) => { + pageSettings = settings || {}; + initializeLlmHelperFromSettings(); + }); + } + + initFooterRelayStatus(); + initSidenavRelaySection(); + await initBlossomSection(); + initAiSectionWithLocalConfig(); + initializeLlmHelperFromSettings(); + await UpdateFooter(); + if (!updateIntervalId) updateIntervalId = setInterval(UpdateFooter, 1000); + + if (!relayActivityListenersBound) { + window.addEventListener('ndkRelayActivity', (event) => { + const { relayUrl, activity } = event.detail || {}; + if (relayUrl && activity) setRelayActivityState(relayUrl, activity); + }); + + window.addEventListener('message', (event) => { + if (event.data && event.data.type === 'relayActivity') { + const { relayUrl, activity } = event.data; + if (relayUrl && activity) setRelayActivityState(relayUrl, activity); + } + }); + + relayActivityListenersBound = true; + } + + bindUi(); + bindSkillEventListeners(); + renderTools(); + clearEditor(); + refreshSkills(); + renderSkillsList(); + renderAdoptedList(); + + authedPageInitialized = true; + } + + async function promptLoginIfNeeded() { + if (isAuthenticated) return true; + await initNDKPage(); + currentPubkey = await getPubkey(); + isAuthenticated = true; + await initializeAuthenticatedPageFeatures(); + return true; + } + + const Logout = async () => { + if (updateIntervalId) { + clearInterval(updateIntervalId); + updateIntervalId = null; + } + clearSub('my'); + clearSub('public'); + clearSub('adoption'); + disconnect(); + if (window.NOSTR_LOGIN_LITE?.logout) await window.NOSTR_LOGIN_LITE.logout(); + localStorage.clear(); + sessionStorage.clear(); + if (window.indexedDB) { + const databases = await window.indexedDB.databases(); + for (const db of databases) { + if (db.name) window.indexedDB.deleteDatabase(db.name); + } + } + location.reload(true); + }; + + (async function main() { + console.log('[skills-edit.html] Starting initialization...'); + try { + initHamburgerMenu(); + + const divSvgHam = document.getElementById('divSvgHam'); + if (divSvgHam) divSvgHam.addEventListener('click', toggleNav); + + const themeToggleButton = document.getElementById('themeToggleButton'); + const logoutButton = document.getElementById('logoutButton'); + + if (themeToggleButton) { + themeToggleButton.addEventListener('click', () => { + isDarkMode = !isDarkMode; + localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); + localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false'); + window.location.reload(); + }); + } + + if (logoutButton) { + logoutButton.addEventListener('click', async () => { + try { + if (!isAuthenticated) { + await promptLoginIfNeeded(); + return; + } + await Logout(); + } catch (error) { + console.error('[skills-edit.html] logout/login failed', error); + } + }); + } + + authMode = resolveAuthModeFromUrl(); + await initializeAuthentication(authMode); + await initializeAuthenticatedPageFeatures(); + + const sidenavWasOpen = localStorage.getItem('sidenavWasOpen'); + if (sidenavWasOpen === 'true') { + localStorage.removeItem('sidenavWasOpen'); + openNav(); + } + + if (!isAuthenticated && (authMode === 'optional' || authMode === 'none')) { + divFooterCenter.textContent = 'Public mode'; + divFooterRight.textContent = 'Sign in from side menu for private features'; + } + + await updateVersionDisplay(); + } catch (error) { + console.error('[skills-edit.html] Initialization failed:', error); + divBody.innerHTML = `
+
❌ Initialization Error
+
${escapeHtml(error?.message || error)}
+
+
`; + } + })(); + \ No newline at end of file diff --git a/README.md b/README.md index fc7d0c9..57a1586 100644 --- a/README.md +++ b/README.md @@ -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.25 **Active build — this project is barely working. Experiment at your own risk.** -> Last release update: v0.2.24 — Improve skills editor trigger filters with per-trigger helper widgets and presets +> Last release update: v0.2.25 — Fix private skill interoperability: NIP-44 encode/decode for kind 31124 with runtime tag normalization - Connects to configured relays with auto-reconnect and relay state transition logging - Publishes configured startup events per relay as each relay becomes connected diff --git a/docs/GENESIS.md b/docs/GENESIS.md index 88e1415..4fcf989 100644 --- a/docs/GENESIS.md +++ b/docs/GENESIS.md @@ -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) diff --git a/genesis.jsonc.example b/genesis.jsonc.example index 0530353..263b86a 100644 --- a/genesis.jsonc.example +++ b/genesis.jsonc.example @@ -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\"}}" + } + ], // ─── HTTP Admin API ──────────────────────────────────────────────── "api": { @@ -60,7 +59,6 @@ ["r", "wss://relay.damus.io"], ["r", "wss://relay.primal.net"] ] - } }, { "kind": 31124, diff --git a/plans/global_llm_alignment.md b/plans/global_llm_alignment.md new file mode 100644 index 0000000..e240303 --- /dev/null +++ b/plans/global_llm_alignment.md @@ -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
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. diff --git a/plans/multi_model_skill_pipelines.md b/plans/multi_model_skill_pipelines.md new file mode 100644 index 0000000..3bb0ae0 --- /dev/null +++ b/plans/multi_model_skill_pipelines.md @@ -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. diff --git a/src/config.c b/src/config.c index 562acc0..401eabc 100644 --- a/src/config.c +++ b/src/config.c @@ -496,6 +496,162 @@ 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; + } + } + + 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 +1400,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)); } @@ -1351,9 +1515,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 +1544,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 +1559,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')"); diff --git a/src/config.h b/src/config.h index bac4182..c17fe89 100644 --- a/src/config.h +++ b/src/config.h @@ -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; diff --git a/src/main.c b/src/main.c index 15fd70d..aafffb7 100644 --- a/src/main.c +++ b/src/main.c @@ -726,7 +726,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 +735,28 @@ 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"); 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,32 +777,16 @@ 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); cJSON_Delete(root); return 0; @@ -844,39 +840,45 @@ 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_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 +886,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) { @@ -1223,13 +1202,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 +1221,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 +1263,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); diff --git a/src/main.h b/src/main.h index 445d18a..1659bbd 100644 --- a/src/main.h +++ b/src/main.h @@ -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 25 +#define DIDACTYL_VERSION "v0.2.25" // Agent metadata #define DIDACTYL_NAME "Didactyl" diff --git a/src/nostr_handler.c b/src/nostr_handler.c index 0f9dc80..84a6008 100644 --- a/src/nostr_handler.c +++ b/src/nostr_handler.c @@ -1028,6 +1028,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 +2423,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", @@ -2921,19 +3206,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 +3268,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; } diff --git a/src/setup_wizard.c b/src/setup_wizard.c index 014853e..3cc3f88 100644 --- a/src/setup_wizard.c +++ b/src/setup_wizard.c @@ -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,28 @@ 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"); 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 +1032,21 @@ 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; } cJSON_Delete(root); @@ -1077,17 +1073,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 +1143,38 @@ 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_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) { diff --git a/src/tools/tool_model.c b/src/tools/tool_model.c index f06ca9f..0562be5 100644 --- a/src/tools/tool_model.c +++ b/src/tools/tool_model.c @@ -5,6 +5,7 @@ #include #include #include +#include #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,115 @@ 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* 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 +160,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 +178,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 +304,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;