diff --git a/.gitignore b/.gitignore index bb788f1..67be676 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ build/ # vendored dependency (added as a subtree/checkout, not tracked here) nostr_core_lib/ + +# auto-generated embedded web content (built by embed_web_files.sh) +src/embedded_web_content.c +src/embedded_web_content.h diff --git a/Makefile b/Makefile index f85fec2..0ca5aad 100644 --- a/Makefile +++ b/Makefile @@ -17,11 +17,24 @@ NOSTR_LIB = ./nostr_core_lib/libnostr_core_x64.a NOSTR_DEPS = -lsecp256k1 -lssl -lcrypto -lcurl -lz -ldl -lpthread -lm BIN := sovereign_browser -SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/history.c src/settings.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c src/profile.c +SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/history.c src/settings.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c src/profile.c src/agent_fs_tools.c src/agent_llm.c src/agent_loop.c src/agent_chat_store.c src/agent_chat.c src/agent_conversations.c src/agent_skills.c src/embedded_web_content.c -$(BIN): $(SRC) $(NOSTR_LIB) +# Web files embedded into the binary as C byte arrays. +WEB_FILES := $(shell find www -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' \) 2>/dev/null) + +# Default goal: build the browser binary. +$(BIN): $(SRC) $(NOSTR_LIB) src/embedded_web_content.c $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(NOSTR_LIB) $(LDLIBS) $(NOSTR_DEPS) +# Auto-generated C byte arrays from www/. Regenerated whenever a web +# file changes (or on first build). Both the .c and .h are produced by +# a single invocation of embed_web_files.sh. +src/embedded_web_content.c: $(WEB_FILES) embed_web_files.sh + ./embed_web_files.sh + +src/embedded_web_content.h: src/embedded_web_content.c + @true + $(NOSTR_LIB): @echo "Building nostr_core_lib..." cd nostr_core_lib && ./build.sh --nips=all diff --git a/README.md b/README.md index fc83566..c8f88cb 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,42 @@ because trust moves to the layer where it belongs: your keys. come) can freely call any endpoint — including FIPS mesh services — without the workarounds traditional browsers force on automators. +## Nostr interaction policy + +sovereign_browser interacts with Nostr via **discrete events only** — no +continuous WebSocket subscriptions. The browser fetches events on demand +(via `relay_fetch.c`) and publishes events on demand (via `settings_sync.c`, +`agent_conversations.c`, `agent_skills.c`). There is no persistent +subscription that receives live updates. + +This is a deliberate architectural choice: + +- **Simplicity** — No subscription lifecycle management, no reconnection + logic, no event deduplication across reconnects. +- **Predictability** — The user controls when data is fetched or published. + No background traffic, no surprise events arriving. +- **Resource efficiency** — No open WebSocket connections consuming memory + and bandwidth while idle. + +**Trade-off:** The browser does not receive live updates. If a conversation +or skill is created in another app (e.g., the client web app), it won't +appear in sovereign_browser until the user explicitly refreshes. UIs that +need fresh data provide **Refresh buttons** that trigger a one-shot fetch. + +### What this means for the chat page + +The `sovereign://agents/chat` page differs from `~/lt/client/www/ai.html` +in this respect: + +- **ai.html** uses NDK with persistent subscriptions — conversations and + skills appear live as they're published to relays. +- **sovereign_browser** fetches conversations and skills on demand — the + user clicks a Refresh button to pull new data from relays. + +Conversations and skills are still stored as the same Nostr event kinds +(30078 for conversations, 31123 for skills) with the same tags, so they're +**compatible** across projects — just not live-synced. + ## Non-goals (for now) - Agent integration, multi-window agent hosts, didactyl hosting, and the diff --git a/VERSION b/VERSION index c4475d3..24ff855 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.26 +0.0.27 diff --git a/embed_web_files.sh b/embed_web_files.sh new file mode 100755 index 0000000..1c5c1bb --- /dev/null +++ b/embed_web_files.sh @@ -0,0 +1,132 @@ +#!/bin/bash +# embed_web_files.sh — Convert www/ web files into C byte arrays +# +# Scans www/**/*.html, www/**/*.css, www/**/*.js and generates +# src/embedded_web_content.c + src/embedded_web_content.h with the +# file contents as C byte arrays. The sovereign:// URI scheme handler +# looks up files by path via get_embedded_file(). +# +# Adapted from ~/lt/c-relay/embed_web_files.sh. + +set -e + +echo "[embed] Embedding web files into C byte arrays..." + +# Output directory for generated files +OUTPUT_DIR="src" +mkdir -p "$OUTPUT_DIR" + +# Function to convert a file to a C byte array +file_to_c_array() { + local input_file="$1" + local array_name="$2" + local output_file="$3" + + # Get file size + local file_size=$(stat -c%s "$input_file" 2>/dev/null || stat -f%z "$input_file" 2>/dev/null || echo "0") + + echo "// Auto-generated from $input_file" >> "$output_file" + echo "static const unsigned char ${array_name}_data[] = {" >> "$output_file" + + # Convert file to hex bytes + hexdump -v -e '1/1 "0x%02x,"' "$input_file" >> "$output_file" + + echo "};" >> "$output_file" + echo "static const size_t ${array_name}_size = $file_size;" >> "$output_file" + echo "" >> "$output_file" +} + +# Generate the header file +HEADER_FILE="$OUTPUT_DIR/embedded_web_content.h" +cat > "$HEADER_FILE" <<'EOF' +// Auto-generated embedded web content header +// Do not edit manually - generated by embed_web_files.sh + +#ifndef EMBEDDED_WEB_CONTENT_H +#define EMBEDDED_WEB_CONTENT_H + +#include + +typedef struct { + const char *path; /* e.g. "agents/chat.html" */ + const unsigned char *data; + size_t size; + const char *mime_type; /* "text/html", "text/css", "application/javascript" */ +} embedded_file_t; + +/* + * Look up an embedded file by its path (e.g. "agents/chat.html"). + * Returns NULL if not found. + */ +const embedded_file_t *get_embedded_file(const char *path); + +#endif /* EMBEDDED_WEB_CONTENT_H */ +EOF + +# Generate the C file +SOURCE_FILE="$OUTPUT_DIR/embedded_web_content.c" +cat > "$SOURCE_FILE" <<'EOF' +// Auto-generated embedded web content +// Do not edit manually - generated by embed_web_files.sh + +#include "embedded_web_content.h" +#include + +EOF + +# Process each web file and build the lookup table +declare -a file_entries + +for file in $(find www -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' -o -name '*.mjs' \) | sort); do + if [ -f "$file" ]; then + # Get path relative to www/ + rel_path="${file#www/}" + + # Create C identifier from path (replace non-alphanumeric with _) + c_name=$(echo "$rel_path" | sed 's/[^a-zA-Z0-9]/_/g' | sed 's/^_//') + + # Determine content type + case "$file" in + *.html) content_type="text/html" ;; + *.css) content_type="text/css" ;; + *.js) content_type="text/javascript" ;; + *.mjs) content_type="text/javascript" ;; + *) content_type="text/plain" ;; + esac + + echo "[embed] $rel_path -> ${c_name} ($content_type)" + + # Generate the byte array + file_to_c_array "$file" "$c_name" "$SOURCE_FILE" + + # Remember for the lookup table (no outer braces — the echo loop + # below wraps each entry in a single set of braces) + file_entries+=("\"$rel_path\", ${c_name}_data, ${c_name}_size, \"$content_type\"") + fi +done + +# Generate the lookup table +echo "// File mapping" >> "$SOURCE_FILE" +echo "static const embedded_file_t embedded_files[] = {" >> "$SOURCE_FILE" +for entry in "${file_entries[@]}"; do + echo " { $entry }," >> "$SOURCE_FILE" +done +echo " {NULL, NULL, 0, NULL} // Sentinel" >> "$SOURCE_FILE" +echo "};" >> "$SOURCE_FILE" +echo "" >> "$SOURCE_FILE" + +# Generate the lookup function +cat >> "$SOURCE_FILE" <<'EOF' +const embedded_file_t *get_embedded_file(const char *path) { + if (!path) return NULL; + for (int i = 0; embedded_files[i].path != NULL; i++) { + if (strcmp(path, embedded_files[i].path) == 0) { + return &embedded_files[i]; + } + } + return NULL; +} +EOF + +echo "[embed] Generated $HEADER_FILE and $SOURCE_FILE" +echo "[embed] Embedded ${#file_entries[@]} files" diff --git a/plans/chat-page-discrete-events.md b/plans/chat-page-discrete-events.md new file mode 100644 index 0000000..1b3dadd --- /dev/null +++ b/plans/chat-page-discrete-events.md @@ -0,0 +1,176 @@ +# Chat Page — Discrete Event Model + +## Goal + +Redesign the `sovereign://agents/chat` page to work with sovereign_browser's +**discrete event** Nostr interaction model (no live subscriptions). The page +needs explicit Save/Refresh buttons for conversations and skills, matching +how the rest of the browser works. + +## What's wrong now + +The current chat page tries to mimic ai.html's live-subscription model: +- It auto-saves conversations after each agent response (via a debounced + `scheduleSave()`) +- It fetches the conversation list on page load but has no way to refresh +- It fetches skills on page load but has no way to refresh +- New Chat saves an empty conversation immediately (good), but there's no + explicit save button for renaming or manual saves + +The user wants explicit control: **Save** to publish, **Refresh** to fetch. + +## Design + +### Conversation list (left pane, top section) + +``` +┌─────────────────────────────┐ +│ [+ New Chat] [↻ Refresh] │ +├─────────────────────────────┤ +│ > Capital of France [×] │ ← selected, click to load +│ EU population [×] │ +│ Hello world [×] │ +└─────────────────────────────┘ +``` + +- **+ New Chat** — Creates a new local session (does NOT save to Nostr yet). + The conversation is "unsaved" until the user sends a message or clicks Save. +- **↻ Refresh** — Fetches the conversation list from Nostr (one-shot relay + query via `relay_fetch.c`) and updates the list. Shows a "Refreshing..." + state while fetching. +- **Click a conversation** — Loads it from the local SQLite cache (fast, no + relay fetch). If the conversation isn't in the cache, fetch it from Nostr. +- **[×] delete button** — Deletes the conversation (publishes kind 5 + tombstone, removes from local cache). + +### Chat thread (right pane) + +``` +┌─────────────────────────────────────────────┐ +│ Capital of France [✏ Rename] [💾] │ ← conversation header +├─────────────────────────────────────────────┤ +│ You: What is the capital of France? │ +│ Assistant: The capital of France is Paris. │ +│ │ +│ [⋯] │ ← dot-menu on each bubble +├─────────────────────────────────────────────┤ +│ [Type a message... ] [Send] │ +└─────────────────────────────────────────────┘ +``` + +- **✏ Rename** — Inline edit the conversation title. Saves to Nostr on Enter + or blur. +- **💾 Save** — Explicitly saves the conversation to Nostr (publishes kind 30078 + with the current messages + title). Shows "Saved!" feedback. +- **Auto-save is removed** — The user must click Save to persist. The only + exception: the first message exchange auto-saves once (to create the Nostr + event), so the conversation appears in the list on other devices. After + that, explicit Save only. + +### Skills list (left pane, bottom section) + +``` +┌─────────────────────────────┐ +│ Skills [↻ Refresh] │ +├─────────────────────────────┤ +│ [✓] Web scraper [requires: │ +│ browser,fs] [×] │ +│ [ ] Code reviewer [requires: │ +│ shell] [×] │ +│ [+ Create Skill] │ +└─────────────────────────────┘ +``` + +- **↻ Refresh** — Fetches skills from Nostr (one-shot relay query) and + updates the list. +- **Checkbox** — Toggle skill selection (saved locally, not to Nostr). +- **[×] delete** — Deletes the skill (kind 5 tombstone) — only for + user-authored skills. +- **+ Create Skill** — Opens the create form. "Publish" button saves to + Nostr. + +### New endpoints needed + +- **`sovereign://agents/conversations/refresh`** (GET) — Triggers a one-shot + relay fetch for kind 30078 events with `t:client-ai-chat-v1` by the user's + pubkey. Stores results in SQLite. Returns `{"status":"refreshed","count":N}` + when done. This is a blocking call (waits for relay response or timeout). + +- **`sovereign://agents/skills/refresh`** (GET) — Triggers a one-shot relay + fetch for kind 31123 events. Stores in SQLite. Returns + `{"status":"refreshed","count":N}`. + +- **`sovereign://agents/conversations/rename`** (GET with `?id=...&title=...`) + — Renames a conversation. Updates the local SQLite cache and re-publishes + the kind 30078 event with the new title. Returns `{"status":"renamed"}`. + +### Changes to existing endpoints + +- **`sovereign://agents/conversations/new`** — Should NOT save to Nostr + immediately. Just create a local session. The conversation is saved to + Nostr on the first explicit Save (or after the first message exchange). + +- **`sovereign://agents/conversations/save`** — Keep as-is (explicit save). + Remove the auto-save call from `applyStatus()` in chat.js. + +### Changes to `www/agents/chat.js` + +1. **Remove `scheduleSave()` / `doSave()` auto-save** — Delete the debounced + auto-save logic. The user clicks Save explicitly. + +2. **Add Refresh button handler** — `refreshConvList()` calls + `sovereign://agents/conversations/refresh`, then re-fetches the list. + +3. **Add Rename button handler** — `renameConv(id)` prompts for a new title, + calls `sovereign://agents/conversations/rename?id=...&title=...`. + +4. **Add Save button handler** — `saveConv()` calls + `sovereign://agents/conversations/save?title=...` explicitly. + +5. **Add Skills Refresh button handler** — `refreshSkills()` calls + `sovereign://agents/skills/refresh`, then re-fetches the skills list. + +6. **First-message auto-save** — After the first agent response in a new + conversation, auto-save once (so the conversation exists on Nostr). After + that, no auto-save. + +### Changes to `src/nostr_bridge.c` + +1. **Add `handle_agents_conversations_refresh()`** — Calls a new + `agent_conversations_refresh()` function that does a one-shot relay fetch. + +2. **Add `handle_agents_skills_refresh()`** — Calls a new + `agent_skills_refresh()` function that does a one-shot relay fetch. + +3. **Add `handle_agents_conversations_rename()`** — Calls + `agent_conversations_rename(id, title)`. + +4. **Update `handle_agents_conversations_new()`** — Remove the immediate + `agent_conversations_save()` call. Just create the local session. + +### Changes to `src/agent_conversations.c` / `src/agent_skills.c` + +1. **Add `agent_conversations_refresh()`** — One-shot relay fetch for kind + 30078 `t:client-ai-chat-v1` events by the user's pubkey. Store in SQLite. + This reuses the relay fetch logic from `relay_fetch.c` but with a specific + filter. + +2. **Add `agent_conversations_rename(id, title)`** — Fetch the existing event, + decrypt, update the title, re-encrypt, re-publish. + +3. **Add `agent_skills_refresh()`** — One-shot relay fetch for kind 31123 + events. Store in SQLite. + +### Changes to `www/agents/chat.html` + +Add the Refresh, Rename, and Save buttons to the HTML structure. + +## Phasing + +**Phase 1:** Add Refresh buttons (conversations + skills) and the refresh +endpoints. Remove auto-save. Add explicit Save button. + +**Phase 2:** Add Rename button and endpoint. + +**Phase 3:** First-message auto-save (so new conversations appear on Nostr +after the first exchange, but subsequent saves are explicit). diff --git a/plans/chat-sidebar-tabbed.md b/plans/chat-sidebar-tabbed.md new file mode 100644 index 0000000..062b208 --- /dev/null +++ b/plans/chat-sidebar-tabbed.md @@ -0,0 +1,174 @@ +# Chat Sidebar + Tabbed Layout + +## Goal + +Redesign the agent chat UI for two use cases: +1. **Tabbed chat page** — Split the current cluttered `sovereign://agents/chat` + page into 3 tabs: Chat, Conversations, Skills. +2. **Sidebar mode** — Show the chat in a narrow left-hand sidebar alongside + a web page on the right, so the user can chat about the page they're + viewing. + +## Current state + +The `sovereign://agents/chat` page has a two-pane layout: +- Left pane: conversation list + skills list (cluttered) +- Right pane: chat messages + input + +This is too much for a sidebar. The user wants tabs to organize it. + +## Design + +### Tabbed chat page + +``` +┌─────────────────────────────────────────────────┐ +│ [Chat] [Conversations] [Skills] │ ← tab bar +├─────────────────────────────────────────────────┤ +│ │ +│ (active tab content) │ +│ │ +└─────────────────────────────────────────────────┘ +``` + +**Tab 1: Chat** (default) +- Chat messages (scrollable) +- Status messages (system bubbles) +- Input area (composer + send + stop) + +**Tab 2: Conversations** +- "+ New Chat" button +- "↻ Refresh" button +- Conversation list (click to load, rename, delete) + +**Tab 3: Skills** +- "↻ Refresh" button +- "+ Create Skill" button +- Skills list (checkboxes, edit, delete) +- Skill editor (inline, expandable) + +When a conversation is selected in Tab 2, switch to Tab 1 automatically. +When a skill is toggled in Tab 3, stay on Tab 3. + +### Sidebar mode + +The sidebar is a **narrow version** of the chat page, shown alongside a +web page. There are two approaches: + +**Option A — Split view in the same tab:** The browser window splits into +two webviews: a narrow left webview showing `sovereign://agents/chat` (in +sidebar mode), and a right webview showing the web page. This requires +GTK-level changes to `tab_manager.c` to support split views. + +**Option B — Separate sidebar panel:** A GTK panel (like a GtkPaned or +GtkBox) on the left side of the browser window, showing the chat UI +natively (not as a webview). This is more work but gives a native feel. + +**Option C — Pop-out sidebar tab:** The chat page detects when it's loaded +in a narrow viewport and switches to a compact sidebar layout (tabs become +icons, messages take full width). This is the simplest — no GTK changes, +just CSS responsive design. + +I recommend **Option A** (split view) for the first implementation — it +reuses the existing chat page in a narrow webview, and the tabbed layout +makes it work well in a narrow space. + +### Split view implementation (Option A) + +``` +┌──────────────────────────────────────────────────────────┐ +│ [tab strip] │ +├─────────────────┬────────────────────────────────────────┤ +│ [Chat][Conv] │ │ +│ [Skills] │ │ +│ │ Web page (right webview) │ +│ Chat messages │ │ +│ ... │ │ +│ │ │ +│ [input area] │ │ +└─────────────────┴────────────────────────────────────────┘ + ← sidebar (300px) ← web page (flex: 1) +``` + +The sidebar is a second webview in the same tab, showing +`sovereign://agents/chat` in a compact layout. The user can toggle the +sidebar on/off via a menu item or keyboard shortcut. + +## Implementation + +### Phase 1 — Tabbed chat page + +1. **`www/agents/chat.html`** — Restructure into 3 tabs: + ```html +
+ + + +
+
+ +
+
+ +
+
+ +
+ ``` + +2. **`www/agents/chat.css`** — Tab bar styling (horizontal buttons, active + state), tab content (only active tab visible). + +3. **`www/agents/chat.js`** — Tab switching logic: + - `switchTab(name)` — hides all tab contents, shows the selected one, + updates the active class on the tab buttons. + - `loadConv()` — after loading a conversation, switch to the "chat" tab. + - `newChat()` — after creating a new chat, switch to the "chat" tab. + +### Phase 2 — Sidebar mode (split view) + +1. **`src/tab_manager.c`** — Add a sidebar webview to each tab: + - Each `tab_info_t` gets a `sidebar_webview` field. + - The tab's container becomes a `GtkPaned` (horizontal) with the sidebar + on the left and the main webview on the right. + - The sidebar webview loads `sovereign://agents/chat` when shown. + - A menu item "Toggle Agent Sidebar" (or keyboard shortcut) shows/hides + the sidebar. + +2. **`src/tab_manager.h`** — Add `sidebar_webview` to `tab_info_t`. + +3. **`www/agents/chat.css`** — Responsive layout: when the viewport is + narrow (< 400px), switch to a compact layout (tab bar becomes icons, + messages take full width, smaller fonts). + +4. **`src/main.c`** — Add a menu item "Toggle Agent Sidebar" and keyboard + shortcut (e.g., Ctrl+Shift+A). + +### Phase 3 — Sidebar-aware behavior + +1. **Agent tools target the main webview** — When the agent calls browser + tools (snapshot, click, eval), they should operate on the **main + webview** (the web page), not the sidebar webview (the chat page). + Update `agent_tools.c` to use the main webview, not the active webview. + +2. **URL bar `;` shortcut** — When the user types `; ` in the URL + bar, if the sidebar is open, send the message to the sidebar's chat. If + not, open the sidebar and send the message. + +## Files to modify + +| File | Change | +|------|--------| +| `www/agents/chat.html` | Restructure into 3 tabs | +| `www/agents/chat.css` | Tab bar styling, responsive sidebar layout | +| `www/agents/chat.js` | Tab switching logic, sidebar-aware behavior | +| `src/tab_manager.c` | Add sidebar webview, GtkPaned split, toggle | +| `src/tab_manager.h` | Add sidebar_webview to tab_info_t | +| `src/main.c` | Add "Toggle Agent Sidebar" menu item + shortcut | +| `src/agent_tools.c` | Browser tools target main webview, not sidebar | + +## Phasing + +**Phase 1:** Tabbed chat page (HTML/CSS/JS only, no C changes). +**Phase 2:** Sidebar mode (split view, GTK changes). +**Phase 3:** Sidebar-aware behavior (agent tools target main webview). diff --git a/plans/cross-project-agent-sync.md b/plans/cross-project-agent-sync.md new file mode 100644 index 0000000..d40684f --- /dev/null +++ b/plans/cross-project-agent-sync.md @@ -0,0 +1,353 @@ +# Cross-Project Agent Sync — sovereign_browser ↔ client ↔ didactyl + +## Goal + +Align sovereign_browser's embedded agent with the patterns used in `~/lt/client` +(the web app) and `~/lt/didactyl` (the C agent daemon), so all three projects +share LLM provider settings, skills, and conversations via Nostr kind 30078 +events. Also fix the immediate bug: messages don't appear in the chat UI. + +## Three workstreams + +``` + ┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐ + │ BugFix │ │ SettingsSync │ │ ChatUI │ + │ Fix chat UI │────▶│ kind 30078 │────▶│ Port ai.html │ + │ message render │ │ d:user-settings │ │ chat layout │ + │ │ │ global_llm sync │ │ + Nostr persist │ + └─────────────────┘ └──────────────────┘ └──────────────────┘ +``` + +--- + +## Workstream 1 — Fix chat UI message rendering (immediate bug) + +**Problem:** The user sends "hello", sees the response in the console log, but +nothing appears in the chat box — neither the user message nor the response. + +**Root cause:** The chat UI's `fetchMessages()` calls +`sovereign://agents/messages` which returns the message history as JSON. The +`renderMessages()` function renders them. But the polling loop calls +`updateStatus()` and `fetchMessages()` every 500ms. The status polling works +(state transitions to complete), but `fetchMessages()` either: +1. Fails silently (XHR error on `sovereign://agents/messages`), or +2. The message count check `msgs.length !== lastCount` never triggers because + the messages endpoint returns an empty array or the wrong format. + +**Fix approach:** +- Add console.error logging to `fetchMessages()` to see the actual XHR response. +- Verify `sovereign://agents/messages` returns the correct JSON array format. +- Check that `agent_chat_store_get_messages()` returns data in the format the + UI expects (`[{role, content, tool_calls, tool_call_id}, ...]`). +- The `renderMessage()` function expects `m.role`, `m.content`, `m.tool_calls` + as fields — verify the store returns these. + +**Files to modify:** +- [`src/nostr_bridge.c`](src/nostr_bridge.c) — `handle_agents_chat_page()` JS, + `handle_agents_messages()` endpoint. + +--- + +## Workstream 2 — Kind 30078 settings sync (shared LLM config) + +### What the client does + +The client stores all user settings in a single kind 30078 event with +`d:user-settings`, NIP-44 self-encrypted. The schema (from +[`~/lt/client/docs/SETTINGS.md`](../client/docs/SETTINGS.md:140)): + +```json +{ + "v": 2, + "updatedAt": 1708646400, + "global_llm": { + "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"] + } +} +``` + +### What didactyl does + +Didactyl (C agent daemon) stores the same `global_llm` shape in its own +pubkey's `d:user-settings` event. See +[`~/lt/client/docs/SETTINGS.md`](../client/docs/SETTINGS.md:357). + +### What sovereign_browser should do — Option A: Merge into d:user-settings + +sovereign_browser already has kind 30078 settings sync via +[`settings_sync.c`](src/settings_sync.c), but it currently uses d-tag +`"sovereign_browser"`. We will **migrate to the shared `d:user-settings`** +event, putting browser-specific settings under a `sovereign_browser` namespace +inside the same encrypted JSON. This aligns with the client and didactyl, and +preserves privacy (no app-specific d-tag leaking which app the user runs). + +**Target `d:user-settings` shape — `global` vs per-app:** + +The `global` namespace holds **all shared settings** — things that should be +the same across every app the user runs. This includes LLM provider +infrastructure (under `global.agent`), and will grow to include zaps, UI +preferences, relay lists, experimental flags, etc. Each app then has its own +top-level namespace for app-specific config. + +```json +{ + "v": 2, + "updatedAt": 1708646400, + + "global": { + "agent": { + "providers": [ + { + "name": "ppq", + "base_url": "https://api.ppq.ai", + "api_key": "sk-...", + "models": ["gpt-4o-mini", "claude-haiku-4.5", "claude-opus-4.6"] + }, + { + "name": "ollama-local", + "base_url": "http://localhost:11434", + "api_key": "", + "models": ["llama3.1", "qwen2.5"] + } + ], + "favorites": ["gpt-4o-mini", "claude-opus-4.6"] + }, + "zaps": { + "defaultAmountSats": 21, + "defaultComment": "", + "preferredMethod": "auto" + }, + "ui": { + "theme": "dark", + "language": "en" + }, + "relays": { }, + "experimental": { } + }, + + "sovereign_browser": { + "agent": { + "provider": "ppq", + "model": "gpt-4o-mini", + "system_prompt": "", + "max_iterations": 100 + }, + "new_tab_url": "", + "tab_bar_position": 0, + "max_tabs": 50, + "bootstrap_relays": "...", + "search_engine": "duckduckgo", + "theme_dark": false, + "shortcuts": { ... } + }, + + "client": { + "ai": { + "provider": "ppq", + "model": "claude-opus-4.6", + "routstr_balance": 0 + } + }, + + "didactyl": { + "agent": { + "provider": "ppq", + "model": "claude-haiku-4.5", + "admin_pubkey": "npub1...", + "dm_protocol": "nip04" + } + } +} +``` + +**Key principle:** `global.agent.providers` is the shared catalog (API keys, +base URLs, model lists). Each app's namespace (`sovereign_browser.agent`, +`client.ai`, `didactyl.agent`) picks which provider+model to use, and holds +app-specific agent config (system prompt, max_iterations, etc.). The `global` +namespace will grow over time to include other shared settings (zaps, UI, +relays). This way: +- API keys are entered once, shared everywhere +- The browser can use `gpt-4o-mini` while the client uses `claude-opus-4.6` +- Each app's agent config is independent +- Other shared settings (zaps, theme, etc.) live alongside `global.agent` +- The `sovereign://agents` config page manages both the shared providers + list AND the browser's per-app agent selection + +**Plan:** +1. **Migrate `settings_sync.c` from d-tag `"sovereign_browser"` to + `"user-settings"`.** Change `SETTINGS_SYNC_D_TAG` to `"user-settings"`. + Move the browser-specific settings into a `sovereign_browser` namespace + inside the encrypted JSON (instead of top-level keys). + +2. **Read `global_llm` from `d:user-settings` on startup.** After login, + fetch the user's kind 30078 `d:user-settings` event, NIP-44 decrypt, + parse `global_llm`, and populate `browser_settings_t` agent fields. Also + parse the `sovereign_browser` namespace for browser settings. + +3. **Read-modify-write on every settings change.** When any settings change + (browser settings OR agent LLM config), do a read-modify-write: + fetch the current event, decrypt, patch the relevant namespace + (`global_llm` or `sovereign_browser`), re-encrypt, publish. This + preserves other apps' namespaces (`global_zaps`, `global_ui`, etc.). + +4. **Multi-provider support.** The `global_llm.providers` array allows + multiple providers. The `sovereign://agents` config page should let the + user manage multiple providers (add/remove/edit), select the active one, + and fetch models per provider. + +5. **Backward compatibility.** On first load after migration, if a + `d:sovereign_browser` event exists but no `d:user-settings` does, read + the old event and migrate the data into the new `d:user-settings` shape. + Then publish the merged event and stop using the old d-tag. + +**Modified files:** +- [`src/settings_sync.h`](src/settings_sync.h) — Change + `SETTINGS_SYNC_D_TAG` to `"user-settings"`. +- [`src/settings_sync.c`](src/settings_sync.c) — Move browser settings into + `sovereign_browser` namespace, add read-modify-write logic, add + `global_llm` read/write, add backward-compat migration from old d-tag. +- [`src/settings.c`](src/settings.c) — After `settings_load_user()`, call + the sync load to fetch from Nostr. +- [`src/nostr_bridge.c`](src/nostr_bridge.c) — `handle_agents_set()` should + trigger a debounced sync publish. +- [`src/settings.h`](src/settings.h) — Add multi-provider fields to + `browser_settings_t` (providers array, active provider index). +- [`src/relay_fetch.c`](src/relay_fetch.c) — Update the fetch filter to use + `d:user-settings` instead of `d:sovereign_browser`. + +--- + +## Workstream 3 — Port ai.html chat UI elements + +### What ai.html has that we want + +From [`~/lt/client/www/ai.html`](../client/www/ai.html): + +1. **Chat layout** — Left pane with conversation list + skills list, right + pane with chat thread + input area. See + [`ai.html:44-80`](../client/www/ai.html:44) for the CSS layout. + +2. **Conversation persistence to Nostr** — Each conversation is saved as a + kind 30078 event with `d:{conversation-id}` and `t:client-ai-chat-v1`, + NIP-44 encrypted. See + [`ai.html:788`](../client/www/ai.html:788) and + [`ai.html:1741`](../client/www/ai.html:1741). + +3. **Skills (kind 31123)** — Public skills that define system prompts, + LLM params, and tool requirements. Users can select multiple skills + whose templates are concatenated into the system prompt. See + [`~/lt/client/plans/ai-skills-integration.md`](../client/plans/ai-skills-integration.md). + +4. **Provider config sidenav** — Provider dropdown, model dropdown, API key + input, Routstr payment integration. + +### What to port to sovereign://agents/chat + +**Phase A — Chat layout:** +- Two-pane layout: conversation list (left) + chat thread (right). +- Conversation list shows saved conversations with titles, click to load. +- "New Chat" button to start a new conversation. +- Chat thread shows messages with role labels, tool call details collapsible. +- Input area at the bottom with send button. + +**Phase B — Conversation persistence to Nostr:** +- Save each conversation as kind 30078, `d:{conversation-id}`, + `t:sovereign-browser-ai-chat-v1`, NIP-44 encrypted. +- Content: `{ schema: 1, messages: [...], title: "..." }`. +- Load conversations from Nostr on page load. +- Delete conversations (kind 5 tombstone). + +**Phase C — Skills (kind 31123):** +- Fetch public skills from Nostr (kind 31123 events). +- Display skills in the left pane below conversations. +- Selecting a skill applies its system prompt template. +- Skills with `requires_tool` tags are highlighted (sovereign_browser HAS + tools, so they're fully usable — unlike ai.html which has no tools). + +**Phase D — Save skills/agents to Nostr:** +- UI to create/edit skills (kind 31123) with system prompt, model, temp. +- Publish to Nostr so they're shared across all three projects. + +**Files to modify:** +- [`src/nostr_bridge.c`](src/nostr_bridge.c) — Major rewrite of + `handle_agents_chat_page()` to include the two-pane layout, conversation + list, skills list. New endpoints: `sovereign://agents/conversations`, + `sovereign://agents/conversations/new`, `sovereign://agents/conversations/delete`, + `sovereign://agents/skills`, `sovereign://agents/skills/save`. +- [`src/agent_chat_store.c`](src/agent_chat_store.c) — Add functions to + load/save conversations as Nostr events (via `relay_fetch.c`). +- New: `src/agent_skills.h` / `src/agent_skills.c` — Fetch, parse, and + apply kind 31123 skills. + +--- + +## Cross-project harmony + +``` + ┌─────────────────────────────────┐ + │ User's Nostr │ + │ │ + │ d:user-settings (k30078) │ + │ └─ global_llm (NIP-44 enc) │ + │ │ + │ d:{convo-id} (k30078) │ + │ └─ conversations (NIP-44) │ + │ │ + │ kind 31123 (public skills) │ + └────────┬───────────┬────────────┘ + │ │ + ┌───────────────────┼───────────┼───────────────────┐ + │ │ │ │ + ▼ ▼ ▼ ▼ + ┌─────────────────┐ ┌───────────────┐ ┌───────────────┐ ┌──────────┐ + │ client │ │ sovereign_ │ │ sovereign_ │ │ didactyl │ + │ ai.html │ │ browser │ │ browser │ │ daemon │ + │ │ │ agents config │ │ agents/chat │ │ │ + │ read/write │ │ read/write │ │ read/write │ │ read/ │ + │ global_llm │ │ global_llm │ │ conversations│ │ write │ + │ read/write │ │ │ │ fetch/publish│ │ global │ + │ conversations │ │ │ │ skills │ │ _llm │ + │ publish/fetch │ │ │ │ │ │ fetch │ + │ skills │ │ │ │ │ │ skills │ + └─────────────────┘ └───────────────┘ └───────────────┘ └──────────┘ +``` + +All three projects share: +- **`global_llm`** in `d:user-settings` — provider, API key, model +- **Conversations** in `d:{convo-id}` — encrypted chat history +- **Skills** in kind 31123 — public system prompt templates + +sovereign_browser's unique advantage: it has **browser tools** (snapshot, +click, eval) + **filesystem/shell tools** that ai.html and didactyl don't +have. Skills with `requires_tool` tags are fully functional in +sovereign_browser. + +--- + +## Phasing + +**Phase 1 (immediate):** Fix chat UI message rendering bug. + +**Phase 2:** Kind 30078 `global_llm` sync — read from Nostr on startup, +write on config change. Single-provider first. + +**Phase 3:** Port ai.html chat layout — two-pane, conversation list, +conversation persistence to Nostr. + +**Phase 4:** Skills (kind 31123) — fetch, display, apply, create. + +**Phase 5:** Multi-provider support in `global_llm`. diff --git a/plans/embedded-agent.md b/plans/embedded-agent.md new file mode 100644 index 0000000..893206c --- /dev/null +++ b/plans/embedded-agent.md @@ -0,0 +1,320 @@ +# Embedded Agent — In-Browser LLM with First-Class MCP Access + +## Concurrency model — long-running tasks + +The agent loop runs on a **background GThread**, not the GTK main thread. This +is critical for long-running tasks like "follow each link, download images, +write a summary for each" which may involve dozens of LLM calls and hundreds +of tool calls. + +```mermaid +sequenceDiagram + participant UI as Chat UI main thread + participant Chat as agent_chat.c + participant Thread as Agent Loop background thread + participant LLM as LLM API + participant Main as GTK main loop + participant Tools as agent_tools_dispatch + + UI->>Chat: POST sovereign://agents/send?text=... + Chat->>Thread: g_thread_new agent_loop_run + Chat-->>UI: 200 OK session started + loop ReAct iterations + Thread->>LLM: POST chat/completions blocking + LLM-->>Thread: response + tool_calls + Thread->>Thread: persist assistant message + alt has tool_calls + loop each tool call + Thread->>Main: g_idle_add dispatch_tool + Main->>Tools: agent_tools_dispatch sync JS eval + Tools-->>Main: result + Main-->>Thread: result via GAsyncQueue + Thread->>Thread: persist tool result + end + else no tool_calls + Thread->>Thread: mark session complete + end + end + Thread->>Main: g_idle_add update_ui final +``` + +### Threading rules + +1. **LLM HTTP calls** happen on the background thread — `soup_session_send` + blocks in-thread, the GTK UI stays responsive. + +2. **Browser tool dispatch** (snapshot, click, eval, etc.) MUST run on the + GTK main thread because WebKitGTK is not thread-safe. The background + thread schedules each tool call via `g_idle_add()` and waits for the + result on a `GAsyncQueue` or a per-call `GMainLoop` (same pattern the + MCP HTTP handler uses for sync JS eval). + +3. **Filesystem + shell tools** run directly on the background thread — they + don't touch GTK or WebKit, so no main-thread hop needed. This keeps + long shell commands from blocking the UI. + +4. **SQLite writes** use the existing `SQLITE_OPEN_FULLMUTEX` connection + (thread-safe). The background thread can call `db_kv_set` / + `agent_chat_store_*` directly. + +5. **UI updates** happen via `g_idle_add()` — the background thread pushes + status updates (current tool, iteration count, partial output) to a + shared struct, and an idle callback renders them in the chat page. + +6. **Cancellation** — a `g_atomic_int` cancel flag is checked at the top of + each loop iteration. The chat UI's "Stop" button sets it. The background + thread exits cleanly at the next check point. + +### Iteration cap + +The default `max_iterations` is **100** (configurable on +`sovereign://agents`), not 20. Long tasks like "follow 30 links" need +multiple tool calls per link (open, snapshot, extract, download, write) — +easily 100+ calls. The cap is a safety valve, not a tight limit. The user +can raise it in settings for very long batch jobs. + +### Status polling + +The chat UI polls `sovereign://agents/status?session=...` every 500ms while +a session is active. The response includes: +- `state`: `idle` | `thinking` | `tool_call` | `complete` | `error` | `cancelled` +- `iteration`: current iteration number +- `current_tool`: name of the tool being executed (if any) +- `last_message`: most recent assistant text (for progressive display) +- `error`: error message if state is `error` + +This gives the user live visibility into long-running tasks without +streaming complexity. + +## Goal + +Embed an LLM-powered agent directly inside sovereign_browser. The user types +`; ` in the URL bar to talk to the agent. The agent has first-class +access to the browser's own MCP tool set (snapshot, click, eval, tabs, etc.) +plus full filesystem and shell access (the browser runs in a dedicated Qubes +qube, so arbitrary command execution is acceptable and intended). + +Provider config (base URL, API key, model name) is managed on a new +`sovereign://agents` internal page. Chat history is persisted per-session in +SQLite so it can be fed back as context on follow-up messages. + +## User-facing flow + +```mermaid +flowchart LR + User[User types in URL bar] --> Check{Starts with semicolon?} + Check -- yes --> Agent[Embedded Agent Chat UI] + Check -- no --> Nav[Normal navigation] + Agent --> LLM[OpenAI-compatible API call] + LLM --> ToolLoop{Tool calls in response?} + ToolLoop -- yes --> Dispatch[agent_tools_dispatch via internal MCP loopback] + Dispatch --> ToolLoop + ToolLoop -- no --> Render[Render assistant message in chat UI] + Render --> User +``` + +## Architecture + +```mermaid +flowchart TB + subgraph URLBar + Entry[GtkEntry on_url_activate] + end + subgraph AgentModule + Router[agent_chat_route_input] + Client[agent_llm.c — HTTP client] + Loop[agent_loop.c — tool-call loop] + Store[agent_chat_store.c — SQLite persistence] + end + subgraph BrowserCore + Tools[agent_tools_dispatch] + FsTools[agent_fs_tools.c — fs + shell tools] + Bridge[nostr_bridge.c — sovereign:// scheme] + end + subgraph External + API[OpenAI-compatible API] + end + subgraph UI + ChatPage[sovereign://agents/chat — HTML/JS chat UI] + ConfigPage[sovereign://agents — provider config] + end + + Entry --> Router + Router -->|chat message| Client + Client -->|HTTPS POST| API + API -->|JSON response| Client + Client --> Loop + Loop -->|tool call| Tools + Loop -->|fs/shell call| FsTools + Tools --> BrowserCore + Loop --> Store + Store --> ChatPage + Bridge --> ChatPage + Bridge --> ConfigPage +``` + +### Key design decisions + +1. **Reuse `agent_tools_dispatch()`** — The embedded agent calls the exact same + C dispatch function the external MCP server uses. No parallel tool + implementation. The agent passes `conn = NULL` so async JS tools use the + sync `agent_js_eval_sync()` path (same as the MCP HTTP handler). + +2. **New system tools (`agent_fs_tools.c`)** — `fs_read`, `fs_write`, + `fs_list`, `fs_mkdir`, `fs_delete`, `shell_exec`. These are registered + alongside the existing browser tools so both the embedded agent and + external MCP clients can use them. Full shell access — the Qubes qube + provides the sandbox. + +3. **OpenAI-compatible HTTP client (`agent_llm.c`)** — Uses libsoup-3.0 + (already linked) to POST to `{base_url}/chat/completions` with the + `tools` array built from the same `tool_defs[]` catalog in + [`agent_mcp.c`](src/agent_mcp.c:130). Streams or polls; parses tool calls + from the response. One client covers OpenAI, OpenRouter, Ollama, LM Studio, + Groq, etc. via base URL + key. + +4. **Tool-call loop (`agent_loop.c`)** — Standard ReAct loop, runs on a + background `GThread` (see Concurrency model above): + - Build messages array (system prompt + persisted history + new user msg). + - Call LLM (blocking HTTP on the background thread). + - If response contains `tool_calls`, dispatch each: + - Browser tools → hop to GTK main thread via `g_idle_add()` + wait on + `GAsyncQueue` (WebKitGTK is not thread-safe). + - Filesystem/shell tools → run directly on the background thread. + - Append tool results to messages, call LLM again. + - Repeat until no more tool calls → render final assistant text. + - Cap at N iterations (default 100, configurable) to prevent infinite + loops. Check cancel flag at the top of each iteration. + +5. **Chat persistence (`agent_chat_store.c`)** — New SQLite tables + (`agent_sessions`, `agent_messages`) in the per-user `browser.db`. Each + session has an id, title, created_at. Messages store role + (user/assistant/tool), content, and tool-call JSON. The URL-bar `;` command + reuses the most recent session (or starts a new one if none exists). + +6. **`sovereign://agents` config page** — Rendered by `nostr_bridge.c` (same + pattern as `sovereign://settings`). Fields: provider name, base URL, API + key, model name, system prompt (optional). Saved via + `sovereign://agents/set?key=...&value=...` to the `key_value` table + (existing `db_kv_set`). API key stored in the key_value table; since this + is a dedicated qube, that's acceptable. + +7. **`sovereign://agents/chat` chat UI** — A lightweight HTML/JS page + (rendered by `nostr_bridge.c`) that: + - Shows the message history (loaded from SQLite via a + `sovereign://agents/messages?session=...` endpoint). + - Has an input box for follow-up messages (POSTed to + `sovereign://agents/send?session=...&text=...`). + - Polls `sovereign://agents/status?session=...` for in-progress tool calls + and streams the assistant's final response. + - The URL-bar `;` shortcut navigates here with the message pre-filled and + auto-submits. + +8. **URL-bar `;` routing** — In [`on_url_activate`](src/tab_manager.c:913), + check if `text[0] == ';'`. If so, extract the message (`text + 1`, trimmed) + and call `agent_chat_route_input(message)` instead of `normalize_url()`. + The function opens `sovereign://agents/chat` in the active tab (or a new + tab if the active tab isn't already the chat page) and kicks off the + agent loop. If the message is empty (`;` alone), just open the chat page + without sending. + +9. **System prompt** — A default system prompt explains the agent's + capabilities: it controls a web browser via MCP tools, has filesystem and + shell access, and should use the snapshot+ref pattern for page + interaction. The user can override this on `sovereign://agents`. + +## New files + +| File | Purpose | +|------|---------| +| `src/agent_llm.h` / `src/agent_llm.c` | OpenAI-compatible HTTP client (libsoup). Sends chat-completions request with tools, parses response + tool_calls. | +| `src/agent_loop.h` / `src/agent_loop.c` | ReAct tool-call loop. Orchestrates LLM calls ↔ tool dispatch. | +| `src/agent_chat_store.h` / `src/agent_chat_store.c` | SQLite persistence for chat sessions and messages. | +| `src/agent_fs_tools.h` / `src/agent_fs_tools.c` | Filesystem + shell tools (fs_read, fs_write, fs_list, fs_mkdir, fs_delete, shell_exec). | +| `src/agent_chat.h` / `src/agent_chat.c` | High-level entry point: `agent_chat_route_input()`, session management, bridges URL-bar → loop → UI. | + +## Modified files + +| File | Change | +|------|--------| +| [`src/tab_manager.c`](src/tab_manager.c:913) | `on_url_activate`: detect `;` prefix, route to `agent_chat_route_input()`. | +| [`src/nostr_bridge.c`](src/nostr_bridge.c:1723) | Add `sovereign://agents`, `sovereign://agents/chat`, `sovereign://agents/set`, `sovereign://agents/messages`, `sovereign://agents/send`, `sovereign://agents/status` route handlers. | +| [`src/agent_mcp.c`](src/agent_mcp.c:130) | Export `tool_defs[]` / `build_tools_list()` (or move to a shared header) so `agent_llm.c` can build the OpenAI tools array from the same catalog. Add fs/shell tool defs. | +| [`src/agent_tools.c`](src/agent_tools.c) | Dispatch fs/shell tools (or route them via a new `agent_fs_tools_dispatch()` called from the same dispatcher). | +| [`src/db.c`](src/db.c) / [`src/db.h`](src/db.h) | Add `agent_sessions` + `agent_messages` tables and CRUD functions. | +| [`src/settings.h`](src/settings.h) / [`src/settings.c`](src/settings.c) | Add agent provider settings fields (base_url, api_key, model, system_prompt). | +| [`Makefile`](Makefile) | Add new `.c` files to `SRC`. | + +## SQLite schema + +```sql +CREATE TABLE IF NOT EXISTS agent_sessions ( + id TEXT PRIMARY KEY, -- UUID + title TEXT, + created_at INTEGER, + updated_at INTEGER +); + +CREATE TABLE IF NOT EXISTS agent_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + role TEXT, -- 'user' | 'assistant' | 'tool' | 'system' + content TEXT, -- message text (or tool result JSON) + tool_calls TEXT, -- JSON array of tool calls (assistant msgs) + tool_call_id TEXT, -- for role='tool': which call this answers + created_at INTEGER, + FOREIGN KEY (session_id) REFERENCES agent_sessions(id) +); +CREATE INDEX IF NOT EXISTS idx_agent_messages_session + ON agent_messages(session_id, created_at); +``` + +## Tool catalog additions (fs + shell) + +| Tool | Description | +|------|-------------| +| `fs_read` | Read a file's contents (text). Params: `path`. | +| `fs_write` | Write text to a file (overwrite). Params: `path`, `content`. | +| `fs_list` | List directory entries. Params: `path`. | +| `fs_mkdir` | Create a directory (recursive). Params: `path`. | +| `fs_delete` | Delete a file or empty directory. Params: `path`. | +| `shell_exec` | Run a shell command, return stdout+stderr+exit code. Params: `command`, `timeout_ms` (default 30000). | + +## OpenAI tools array format + +The `agent_llm.c` client builds the `tools` field from the shared +`tool_defs[]` array, converting each entry to the OpenAI format: + +```json +{ + "type": "function", + "function": { + "name": "snapshot", + "description": "Get the accessibility tree...", + "parameters": { ...schema... } + } +} +``` + +## Concurrency note + +See the **Concurrency model** section at the top of this document for the +full design. Summary: the agent loop runs on a background `GThread`. LLM +HTTP calls block in-thread. Browser tool calls hop to the GTK main thread +via `g_idle_add()` + `GAsyncQueue` (WebKitGTK is not thread-safe). +Filesystem and shell tools run directly on the background thread. The UI +polls `sovereign://agents/status` for live progress. A cancel flag allows +the user to stop long-running tasks. + +## Phasing + +The work breaks into two phases that can be implemented sequentially: + +**Phase 1 — Foundation:** fs/shell tools, LLM client, tool-call loop, chat +persistence, `sovereign://agents` config page, URL-bar `;` routing, basic +chat UI. After Phase 1 the example task ("save links to ~/temp/links.txt") +works end-to-end. + +**Phase 2 — Polish:** streaming responses, tool-call progress display in the +chat UI, session list / history sidebar, editable system prompt per session, +multi-session support from the URL bar, error recovery UI. diff --git a/plans/embedded-web-content.md b/plans/embedded-web-content.md new file mode 100644 index 0000000..376e61f --- /dev/null +++ b/plans/embedded-web-content.md @@ -0,0 +1,179 @@ +# Embedded Web Content — sovereign:// pages from files + +## Problem + +The `sovereign://agents/chat` page is hardcoded as a ~800-line C string literal +in [`src/nostr_bridge.c`](src/nostr_bridge.c) (`handle_agents_chat_page()`). +This causes: +- **Bugs** — C string escaping errors (`\"`, `\\`, `%%`) are easy to make and + hard to spot. The current "messages don't render" and "New Chat doesn't work" + bugs are likely caused by this. +- **Maintenance nightmare** — No syntax highlighting, no IDE support, no + formatting tools work on the embedded HTML/CSS/JS. +- **Can't copy ai.html** — The user wants to port `~/lt/client/www/ai.html`'s + structure, but that's a standalone HTML file with separate CSS/JS. Embedding + it as a C string would be thousands of lines of escaped strings. + +## Solution: Copy c-relay's approach + +`~/lt/c-relay` solves this with a build-time embedding script: + +1. **Author web files as normal files** in a `www/` directory (HTML, CSS, JS — + no escaping needed, full IDE support). +2. **`embed_web_files.sh`** — A shell script that runs at build time. It uses + `hexdump` to convert each file into a C byte array + (`0x3c,0x21,0x44,...`) in an auto-generated `src/embedded_web_content.c`. +3. **`embedded_web_content.h`** — Declares `embedded_file_t` and + `get_embedded_file(path)`. +4. **`embedded_web_content.c`** — Auto-generated byte arrays + a lookup table + mapping paths to arrays. +5. **The `sovereign://` handler** calls `get_embedded_file()` to serve the + embedded content instead of building HTML with `g_strdup_printf()`. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ www/ (normal files, edit freely) │ +│ agents/chat.html (standalone HTML) │ +│ agents/chat.css (separate CSS) │ +│ agents/chat.js (separate JS) │ +│ agents/config.html (provider config page) │ +│ agents/config.css │ +│ agents/config.js │ +│ settings.html │ +│ bookmarks.html │ +│ ... │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ embed_web_files.sh (build time) +┌─────────────────────────────────────────────────────────────┐ +│ src/embedded_web_content.c (auto-generated, do not edit) │ +│ static const unsigned char agents_chat_html_data[] = { │ +│ 0x3c,0x21,0x44,0x4f,0x43,0x54,0x59,0x50,0x45,... │ +│ }; │ +│ static const size_t agents_chat_html_size = 12345; │ +│ ... │ +│ static embedded_file_t embedded_files[] = { │ +│ {"agents/chat.html", agents_chat_html_data, ...}, │ +│ {"agents/chat.css", agents_chat_css_data, ...}, │ +│ {"agents/chat.js", agents_chat_js_data, ...}, │ +│ {NULL, NULL, 0, NULL} │ +│ }; │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ get_embedded_file(path) +┌─────────────────────────────────────────────────────────────┐ +│ src/nostr_bridge.c (sovereign:// handler) │ +│ on_sovereign_scheme(): │ +│ if (strncmp(uri, "sovereign://agents/chat", ...) == 0) │ +│ serve_embedded_file(request, "agents/chat.html"); │ +│ ... │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Implementation plan + +### Phase 1 — Set up the embedding infrastructure + +1. **Create `www/` directory** at the project root for web files. + +2. **Create `embed_web_files.sh`** (copy from c-relay, adapt for our file + structure). It scans `www/**/*.html`, `www/**/*.css`, `www/**/*.js` and + generates `src/embedded_web_content.c` + `src/embedded_web_content.h`. + +3. **Create `src/embedded_web_content.h`**: + ```c + typedef struct { + const char *path; /* e.g. "agents/chat.html" */ + const unsigned char *data; + size_t size; + const char *mime_type; /* "text/html", "text/css", "application/javascript" */ + } embedded_file_t; + + const embedded_file_t *get_embedded_file(const char *path); + ``` + +4. **Add a `serve_embedded_file()` helper** in `nostr_bridge.c` that calls + `get_embedded_file()` and responds with the bytes via `respond_bytes()`. + +5. **Update `Makefile`** to run `embed_web_files.sh` before compiling, and + add `src/embedded_web_content.c` to `SRC`. + +6. **Update `.gitignore`** to ignore `src/embedded_web_content.c` and + `src/embedded_web_content.h` (they're auto-generated). + +### Phase 2 — Migrate the chat page to files + +1. **Create `www/agents/chat.html`** — Extract the HTML structure from + `handle_agents_chat_page()` into a standalone HTML file. No C string + escaping needed. + +2. **Create `www/agents/chat.css`** — Extract the CSS into a separate file. + Link it from the HTML: ``. + +3. **Create `www/agents/chat.js`** — Extract the JavaScript into a separate + file. Link it: ``. + +4. **Add routes** in `on_sovereign_scheme()`: + - `sovereign://agents/chat` → serve `agents/chat.html` + - `sovereign://agents/chat.css` → serve `agents/chat.css` + - `sovereign://agents/chat.js` → serve `agents/chat.js` + +5. **Fix the bugs** while extracting: + - **Messages not rendering:** The `renderMessage()` function must include + `data-msg-index="N"` on `.msg-bubble-content` and `.msg-bubble-menu-host` + elements so `renderBubbleContent()` and `mountDotMenu()` can find them. + - **New Chat not working:** Verify `sovereign://agents/conversations/new` + creates a new session and sets it as active. The `newChat()` JS must + clear the message list and reset `lastCount = -1`. + +### Phase 3 — Migrate other sovereign:// pages + +Once the chat page works, migrate the other pages the same way: +- `sovereign://agents` (config page) → `www/agents/config.html` + `.css` + `.js` +- `sovereign://settings` → `www/settings.html` + `.css` + `.js` +- `sovereign://bookmarks` → `www/bookmarks.html` + `.css` + `.js` +- `sovereign://profile` → `www/profile.html` + `.css` + `.js` + +Each page keeps its C-side data endpoints (`sovereign://agents/set`, +`sovereign://agents/messages`, etc.) but the page HTML/CSS/JS moves to files. + +### Phase 4 — Port ai.html features + +With the chat page as a standalone HTML file, we can directly copy: +- The ai.html message bubble CSS (from `messaging-ui.css`) +- The dot-menu CSS (from `dot-menu.css`) +- The markdown renderer (use `marked.js` from the client's vendor libs, or + keep our minimal renderer) +- The conversation list layout +- The skills list layout +- The provider config sidenav + +## Benefits + +- **No C string escaping** — Edit HTML/CSS/JS normally with full IDE support +- **Syntax highlighting** — IDEs recognize `.html`, `.css`, `.js` files +- **Can copy ai.html directly** — Just copy the relevant sections +- **Smaller `nostr_bridge.c`** — The 800-line chat page string literal is gone +- **Easier debugging** — Browser dev tools show the actual HTML, not escaped strings +- **Build-time embedding** — Files are bundled in the binary, no external files needed at runtime + +## Files to create + +| File | Purpose | +|------|---------| +| `embed_web_files.sh` | Build script: converts www/ files to C byte arrays | +| `src/embedded_web_content.h` | Header for the embedded file lookup | +| `src/embedded_web_content.c` | Auto-generated byte arrays (gitignored) | +| `www/agents/chat.html` | Chat page HTML | +| `www/agents/chat.css` | Chat page CSS | +| `www/agents/chat.js` | Chat page JS | + +## Files to modify + +| File | Change | +|------|--------| +| `Makefile` | Run embed script, add embedded_web_content.c to SRC | +| `.gitignore` | Ignore auto-generated embedded_web_content.* | +| `src/nostr_bridge.c` | Add serve_embedded_file(), route to embedded files, remove old handle_agents_chat_page() string literal | diff --git a/plans/system-prompt-as-skill.md b/plans/system-prompt-as-skill.md new file mode 100644 index 0000000..d2fbe5c --- /dev/null +++ b/plans/system-prompt-as-skill.md @@ -0,0 +1,181 @@ +# System Prompt as the Default Sovereign Browser Skill + +## Goal + +The system prompt on `sovereign://agents` is essentially the default skill for +sovereign_browser. Instead of having a separate "System Prompt" field, we +treat it as an **unsaved skill** that the user can edit, save, and share — +matching how ai.html handles skills. + +## Current state + +- `sovereign://agents` (config page) has a "System Prompt" textarea saved to + `sovereign_browser.agent.system_prompt` in the `d:user-settings` event. +- `sovereign://agents/chat` has a skills list (kind 31123) with checkboxes. +- The agent loop (`agent_loop.c`) uses the system prompt from settings, OR the + concatenated templates of selected skills (if any skills are selected). + +## Desired state + +- **Rename "System Prompt" to "Sovereign Browser Skill"** on the config page. +- The system prompt is presented as an **unsaved skill** — a skill that exists + locally but hasn't been published to Nostr yet. +- In the chat page, the "Sovereign Browser Skill" appears in the skills list + as an unsaved skill (with a "Save" button to publish it as kind 31123). +- The user can **edit** the skill (name, description, template, tools) inline, + matching ai.html's `renderSkillsEditor()` pattern. +- The user can **save** the skill to Nostr (publishes kind 31123), making it + available to other apps (client, didactyl). +- The user can **modify** saved skills the same way (if they're the author). + +## Design + +### On `sovereign://agents` (config page) + +Replace the "System Prompt" section with a "Sovereign Browser Skill" section: + +``` +┌─────────────────────────────────────────────┐ +│ Sovereign Browser Skill │ +├─────────────────────────────────────────────┤ +│ Name: [Sovereign Browser Default ] │ +│ Description: [Default agent skill ] │ +│ Template: │ +│ ┌───────────────────────────────────────┐ │ +│ │ You are an AI assistant embedded in │ │ +│ │ a web browser. You have access to... │ │ +│ └───────────────────────────────────────┘ │ +│ Requires tools: [browser, fs, shell ] │ +│ │ +│ [💾 Save as Skill] (publishes to Nostr) │ +└─────────────────────────────────────────────┘ +``` + +- The template is the current system prompt value. +- "Save as Skill" publishes a kind 31123 event with the skill content. +- The skill is saved to `sovereign_browser.agent.system_prompt` locally (as + now) AND optionally published to Nostr as a skill. + +### On `sovereign://agents/chat` (chat page) + +The skills list shows: +1. **Sovereign Browser Skill** (unsaved) — always at the top, with a + checkbox (selected by default), an edit button, and a "Save" button. +2. **Saved skills** (kind 31123) — fetched from Nostr, with checkboxes, + edit buttons (if user-authored), and delete buttons. + +When the user selects the Sovereign Browser Skill, its template is used as +the system prompt (concatenated with any other selected skills, matching the +existing `agent_skills_build_system_prompt()` logic). + +### Skill editor (matching ai.html) + +When the user clicks "Edit" on a skill (or the Sovereign Browser Skill), an +inline editor expands (like ai.html's `renderSkillsEditor()`): + +``` +▼ Sovereign Browser Default + ┌──────────────────────────────────────────┐ + │ Name: [Sovereign Browser Default ] │ + │ Description: [Default agent skill ] │ + │ Template: │ + │ ┌────────────────────────────────────┐ │ + │ │ You are an AI assistant embedded...│ │ + │ └────────────────────────────────────┘ │ + │ Requires tools: [browser, fs, shell ] │ + │ │ + │ [💾 Save / Update] [Cancel] │ + └──────────────────────────────────────────┘ +``` + +- For the Sovereign Browser Skill (unsaved): "Save" publishes it to Nostr as + a new kind 31123 event AND updates the local system prompt. +- For saved skills (user-authored): "Save / Update" re-publishes the kind + 31123 event with the edited content. +- For saved skills (not user-authored): "View Only (not your skill)" — no + edit, matching ai.html's `canSave` logic. + +## Implementation + +### Phase 1 — Rename + restructure the config page + +1. **`www/agents/config.html` + `config.js`** — Replace the "System Prompt" + section with a "Sovereign Browser Skill" section with Name, Description, + Template, and Requires Tools fields. Add a "Save as Skill" button. + +2. **`src/nostr_bridge.c`** — Update `handle_agents_set()` to handle the new + skill fields (`agent.skill_name`, `agent.skill_description`, + `agent.skill_template`, `agent.skill_requires_tools`). These are saved to + `sovereign_browser.agent` in the `d:user-settings` event (replacing the + old `system_prompt` field). + +3. **`src/agent_loop.c`** — Update the system prompt logic: use + `sovereign_browser.agent.skill_template` as the default system prompt + (instead of `system_prompt`). If skills are selected, concatenate their + templates (as now). + +### Phase 2 — Show the Sovereign Browser Skill in the chat page + +1. **`www/agents/chat.js`** — In `renderSkillList()`, add the Sovereign + Browser Skill at the top of the list as an "unsaved" skill. It has: + - A checkbox (selected by default) + - The skill name (from `sovereign_browser.agent.skill_name`) + - An "Edit" button that opens the inline editor + - A "Save" button that publishes it to Nostr + +2. **`src/agent_skills.c`** — Add a function + `agent_skills_get_default()` that returns the Sovereign Browser Skill + from the local settings (not from Nostr). This is used by the chat page + to show the unsaved skill. + +3. **`src/nostr_bridge.c`** — Add a `sovereign://agents/skills/default` + endpoint that returns the Sovereign Browser Skill from settings. + +### Phase 3 — Skill editor in the chat page + +1. **`www/agents/chat.js`** — Implement `renderSkillEditor(skill)` matching + ai.html's `renderSkillsEditor()`: + - Expandable `
` for each selected skill + - Name, Description, Template, Requires Tools fields + - "Save / Update" button (for user-authored or unsaved skills) + - "View Only" for skills not authored by the user + +2. **`src/nostr_bridge.c`** — Add a `sovereign://agents/skills/update` + endpoint that re-publishes an existing skill (kind 31123) with edited + content. For the Sovereign Browser Skill, this also updates the local + settings. + +3. **`src/agent_skills.c`** — Add `agent_skills_update(d_tag, name, + description, content, requires_tools)` that fetches the existing skill + event, updates its content, re-signs, and re-publishes. + +### Phase 4 — Wire the default skill into the agent loop + +1. **`src/agent_loop.c`** — The system prompt is now: + - If the Sovereign Browser Skill is selected (default): use its template + - If other skills are selected: concatenate their templates + - If no skills selected: use the Sovereign Browser Skill template as + fallback (it's always the default) + +2. **`src/agent_skills.c`** — Update `agent_skills_build_system_prompt()` to + always include the Sovereign Browser Skill template (either as the base + prompt, or concatenated with selected skills). + +## Files to modify + +| File | Change | +|------|--------| +| `www/agents/config.html` | Rename "System Prompt" to "Sovereign Browser Skill", restructure fields | +| `www/agents/config.js` | Handle new skill fields, "Save as Skill" button | +| `www/agents/chat.js` | Show Sovereign Browser Skill in skills list, implement skill editor | +| `www/agents/chat.css` | Style the skill editor (matching ai.html) | +| `src/nostr_bridge.c` | New endpoints: `skills/default`, `skills/update`; update `handle_agents_set()` | +| `src/agent_skills.c` / `.h` | `agent_skills_get_default()`, `agent_skills_update()` | +| `src/agent_loop.c` | Use skill template as system prompt | +| `src/settings.h` / `settings.c` | Rename `agent_llm_system_prompt` to `agent_skill_template`, add `agent_skill_name`, `agent_skill_description`, `agent_skill_requires_tools` | + +## Reference + +- `~/lt/client/www/ai.html` lines 1454-1540 — `renderSkillsEditor()` pattern +- `~/lt/client/www/ai.html` lines 1534-1538 — "Save / Update Skill" button +- `~/lt/client/www/ai.html` lines 1471 — `canSave` logic (only author can edit) diff --git a/src/agent_chat.c b/src/agent_chat.c new file mode 100644 index 0000000..da403b4 --- /dev/null +++ b/src/agent_chat.c @@ -0,0 +1,49 @@ +/* + * agent_chat.c — high-level entry point for the embedded agent chat + * + * Bridges the URL bar to the agent loop and the chat UI page. + */ + +#include "agent_chat.h" +#include "agent_loop.h" +#include "agent_chat_store.h" +#include "tab_manager.h" + +#include +#include + +#define AGENT_CHAT_URL "sovereign://agents/chat" + +int agent_chat_route_input(const char *message) { + tab_info_t *tab = tab_manager_get_active(); + if (tab == NULL || tab->webview == NULL) { + g_printerr("[agent_chat] no active tab\n"); + return -1; + } + + /* Sidebar-aware behavior: if the agent sidebar is already visible, + * just send the message (don't navigate away from the current page). + * If the sidebar is not visible, toggle it on first — this opens + * the chat in the sidebar alongside the current web page, then + * sends the message. This lets the user chat about the page they're + * viewing without navigating away from it. */ + if (!tab_manager_sidebar_visible()) { + tab_manager_toggle_sidebar(); + } + + /* If a non-empty message was provided, start the agent loop. The + * sidebar webview polls sovereign://agents/messages and will pick + * up the agent's responses automatically. */ + if (message != NULL && message[0] != '\0') { + if (agent_loop_run(message) != 0) { + g_printerr("[agent_chat] agent_loop_run failed\n"); + return -1; + } + } + + return 0; +} + +const char *agent_chat_session_id(void) { + return agent_chat_store_session_id(); +} diff --git a/src/agent_chat.h b/src/agent_chat.h new file mode 100644 index 0000000..93d5dd4 --- /dev/null +++ b/src/agent_chat.h @@ -0,0 +1,47 @@ +/* + * agent_chat.h — high-level entry point for the embedded agent chat + * + * Bridges the URL bar to the agent loop and the chat UI page. When the + * user types "; " in the URL bar, tab_manager calls + * agent_chat_route_input(), which navigates the active tab to + * sovereign://agents/chat and kicks off the agent loop. + */ + +#ifndef AGENT_CHAT_H +#define AGENT_CHAT_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Route a user message from the URL bar to the embedded agent. + * Called when the user types "; " in the URL bar. + * + * Sidebar-aware behavior: + * 1. If the agent sidebar is not visible, toggle it on (this opens the + * chat page in a narrow sidebar alongside the current web page). + * 2. If message is non-NULL and non-empty, starts the agent loop with + * that message. The sidebar webview polls for messages and shows + * the agent's responses. + * 3. If message is NULL or empty, just opens the sidebar (no message + * sent). + * + * This lets the user chat about the page they're viewing without + * navigating away from it. + * + * Returns 0 on success, -1 on error. + */ +int agent_chat_route_input(const char *message); + +/* + * Get the current session ID (for the chat UI to use). + * Returns NULL if no session exists. The string is owned by the chat store. + */ +const char *agent_chat_session_id(void); + +#ifdef __cplusplus +} +#endif + +#endif /* AGENT_CHAT_H */ diff --git a/src/agent_chat_store.c b/src/agent_chat_store.c new file mode 100644 index 0000000..9886955 --- /dev/null +++ b/src/agent_chat_store.c @@ -0,0 +1,177 @@ +/* + * agent_chat_store.c — thin wrapper over db.c for agent chat sessions + * + * Manages the "current session" concept and provides convenience + * functions for adding messages and loading history in OpenAI format. + */ + +#include "agent_chat_store.h" +#include "db.h" + +#include +#include + +/* ── Internal state ────────────────────────────────────────────────── */ + +/* Owned by the store; freed on replacement or shutdown. */ +static char *g_current_session_id = NULL; + +/* ── Helpers ───────────────────────────────────────────────────────── */ + +/* + * Replace g_current_session_id with a newly-allocated copy of `id`. + * Frees the previous value. Takes ownership of nothing (copies `id`). + * Returns a pointer to the stored string (owned by the store). + */ +static const char *store_session_id(const char *id) { + if (id == NULL) return NULL; + if (g_current_session_id) { + g_free(g_current_session_id); + } + g_current_session_id = g_strdup(id); + return g_current_session_id; +} + +/* ── Public API ────────────────────────────────────────────────────── */ + +const char *agent_chat_store_get_session(void) { + if (g_current_session_id) { + return g_current_session_id; + } + + /* Try to load the latest existing session. */ + char *latest = db_agent_session_get_latest(); + if (latest) { + const char *stored = store_session_id(latest); + g_free(latest); + return stored; + } + + /* No sessions exist — create a new one. */ + char *id = db_agent_session_create(NULL); + if (id == NULL) { + g_printerr("[agent_chat_store] failed to create new session\n"); + return NULL; + } + const char *stored = store_session_id(id); + g_free(id); + return stored; +} + +const char *agent_chat_store_new_session(const char *title) { + char *id = db_agent_session_create(title); + if (id == NULL) { + g_printerr("[agent_chat_store] failed to create new session\n"); + return NULL; + } + const char *stored = store_session_id(id); + g_free(id); + return stored; +} + +const char *agent_chat_store_set_session(const char *session_id) { + if (session_id == NULL) return NULL; + return store_session_id(session_id); +} + +int agent_chat_store_add_user_message(const char *content) { + const char *sid = agent_chat_store_get_session(); + if (sid == NULL) return -1; + if (content == NULL) return -1; + + int row = db_agent_message_add(sid, "user", content, NULL, NULL); + if (row < 0) { + g_printerr("[agent_chat_store] failed to add user message\n"); + return -1; + } + return 0; +} + +int agent_chat_store_add_assistant_message(const char *content, + const char *tool_calls_json) { + const char *sid = agent_chat_store_get_session(); + if (sid == NULL) return -1; + + int row = db_agent_message_add(sid, "assistant", content, + tool_calls_json, NULL); + if (row < 0) { + g_printerr("[agent_chat_store] failed to add assistant message\n"); + return -1; + } + return 0; +} + +int agent_chat_store_add_tool_result(const char *tool_call_id, + const char *result_json) { + const char *sid = agent_chat_store_get_session(); + if (sid == NULL) return -1; + if (tool_call_id == NULL || result_json == NULL) return -1; + + int row = db_agent_message_add(sid, "tool", result_json, NULL, + tool_call_id); + if (row < 0) { + g_printerr("[agent_chat_store] failed to add tool result\n"); + return -1; + } + return 0; +} + +cJSON *agent_chat_store_get_messages(void) { + const char *sid = agent_chat_store_get_session(); + if (sid == NULL) return NULL; + + cJSON *raw = db_agent_message_list(sid); + if (raw == NULL) { + g_printerr("[agent_chat_store] failed to load messages\n"); + return NULL; + } + + /* Convert to OpenAI-format array. */ + cJSON *out = cJSON_CreateArray(); + cJSON *msg; + cJSON_ArrayForEach(msg, raw) { + cJSON *role_item = cJSON_GetObjectItemCaseSensitive(msg, "role"); + const char *role = (cJSON_IsString(role_item)) + ? role_item->valuestring : ""; + + cJSON *content_item = cJSON_GetObjectItemCaseSensitive(msg, "content"); + cJSON *tool_calls_item = cJSON_GetObjectItemCaseSensitive(msg, "tool_calls"); + cJSON *tool_call_id_item = cJSON_GetObjectItemCaseSensitive(msg, "tool_call_id"); + + cJSON *obj = cJSON_CreateObject(); + cJSON_AddStringToObject(obj, "role", role); + + /* content: include only if non-null. */ + if (cJSON_IsString(content_item)) { + cJSON_AddStringToObject(obj, "content", content_item->valuestring); + } + + /* tool_calls: parse the stored JSON string into an array object. */ + if (cJSON_IsString(tool_calls_item) && + tool_calls_item->valuestring[0] != '\0') { + cJSON *parsed = cJSON_Parse(tool_calls_item->valuestring); + if (parsed) { + cJSON_AddItemToObject(obj, "tool_calls", parsed); + } else { + /* Store the raw string if it isn't valid JSON. */ + cJSON_AddStringToObject(obj, "tool_calls", + tool_calls_item->valuestring); + } + } + + /* tool_call_id: for role="tool". */ + if (cJSON_IsString(tool_call_id_item)) { + cJSON_AddStringToObject(obj, "tool_call_id", + tool_call_id_item->valuestring); + } + + cJSON_AddItemToArray(out, obj); + } + + cJSON_Delete(raw); + return out; +} + +const char *agent_chat_store_session_id(void) { + return g_current_session_id; +} diff --git a/src/agent_chat_store.h b/src/agent_chat_store.h new file mode 100644 index 0000000..5e71a08 --- /dev/null +++ b/src/agent_chat_store.h @@ -0,0 +1,89 @@ +/* + * agent_chat_store.h — thin wrapper over db.c for agent chat sessions + * + * Manages the "current session" concept and provides convenience + * functions for adding messages and loading history in OpenAI format. + * + * The store keeps a static g_current_session_id internally. When + * agent_chat_store_get_session() is called and no current session is + * set, it loads the latest session from the database (or creates a new + * one if none exists). + * + * Thread safety: the underlying database uses SQLITE_OPEN_FULLMUTEX, so + * these functions can be called from the background agent thread. + */ + +#ifndef AGENT_CHAT_STORE_H +#define AGENT_CHAT_STORE_H + +#include + +/* cJSON is in the vendored nostr_core_lib */ +#include "../nostr_core_lib/cjson/cJSON.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Get or create the current chat session. If no session exists, creates + * a new one. Returns the session ID (owned by the store, do not free). + * Returns NULL on error. + */ +const char *agent_chat_store_get_session(void); + +/* + * Start a new chat session. Returns the session ID (owned by the store). + * Returns NULL on error. + */ +const char *agent_chat_store_new_session(const char *title); + +/* + * Set the current session to an existing session id. The session must + * already exist in the database (e.g. created by db_agent_session_create). + * Returns a pointer to the stored session id (owned by the store), or + * NULL on error. + */ +const char *agent_chat_store_set_session(const char *session_id); + +/* + * Add a user message to the current session. + * Returns 0 on success, -1 on error. + */ +int agent_chat_store_add_user_message(const char *content); + +/* + * Add an assistant message (with optional tool_calls) to the current session. + * Returns 0 on success, -1 on error. + */ +int agent_chat_store_add_assistant_message(const char *content, + const char *tool_calls_json); + +/* + * Add a tool result message to the current session. + * Returns 0 on success, -1 on error. + */ +int agent_chat_store_add_tool_result(const char *tool_call_id, + const char *result_json); + +/* + * Load the full message history for the current session as a cJSON array + * (suitable for sending to the LLM API). Each message is in OpenAI format: + * {"role":"user","content":"..."} + * {"role":"assistant","content":"...","tool_calls":[...]} + * {"role":"tool","tool_call_id":"...","content":"..."} + * Returns NULL on error. Caller must cJSON_Delete(). + */ +cJSON *agent_chat_store_get_messages(void); + +/* + * Get the session ID for the current session (or NULL if none). + * The returned string is owned by the store. + */ +const char *agent_chat_store_session_id(void); + +#ifdef __cplusplus +} +#endif + +#endif /* AGENT_CHAT_STORE_H */ diff --git a/src/agent_conversations.c b/src/agent_conversations.c new file mode 100644 index 0000000..996406e --- /dev/null +++ b/src/agent_conversations.c @@ -0,0 +1,799 @@ +/* + * agent_conversations.c — Nostr conversation persistence for agent chat + * + * Saves and loads agent chat conversations as NIP-78 (kind 30078) addressable + * events. Each conversation is stored with: + * d-tag: "{uuid}" (no prefix — matches ai.html for cross-app sharing) + * t-tag: "client-ai-chat-v1" + * + * The event content is NIP-44 encrypted (self-to-self) JSON: + * { "schema": 1, "title": "...", "messages": [ {role, content, tool_calls, tool_call_id}, ... ] } + * + * Reuses the NIP-44 encryption + event signing + relay publishing patterns + * from settings_sync.c and bookmarks.c. + * + * See plans/cross-project-agent-sync.md (Workstream 3) for the full design. + */ + +#include "agent_conversations.h" +#include "agent_chat_store.h" +#include "db.h" +#include "settings.h" + +#include +#include +#include +#include + +#include "nostr_core/nostr_core.h" +#include "../nostr_core_lib/cjson/cJSON.h" + +/* ── Global state ───────────────────────────────────────────────────── */ + +static nostr_signer_t *g_signer = NULL; +static char g_pubkey[65] = {0}; +static int g_have_signer = 0; + +/* ── Init ───────────────────────────────────────────────────────────── */ + +void agent_conversations_init(nostr_signer_t *signer, + const char *pubkey_hex) { + g_signer = signer; + g_have_signer = (signer != NULL); + if (pubkey_hex) { + snprintf(g_pubkey, sizeof(g_pubkey), "%s", pubkey_hex); + } else { + g_pubkey[0] = '\0'; + } +} + +void agent_conversations_set_signer(nostr_signer_t *signer, + const char *pubkey_hex) { + agent_conversations_init(signer, pubkey_hex); +} + +/* ── Helpers ────────────────────────────────────────────────────────── */ + +/* Parse bootstrap relays from settings into an array. + * Returns the count. Fills urls_out (pointers into relay_buf). + * Caller must g_free(relay_buf) after use. (Same pattern as + * settings_sync.c and bookmarks.c.) */ +static int parse_relays(char **relay_buf, const char **urls_out, int max) { + const browser_settings_t *s = settings_get(); + *relay_buf = g_strdup(s->bootstrap_relays); + int count = 0; + char *saveptr = NULL; + char *line = strtok_r(*relay_buf, "\n\r", &saveptr); + while (line != NULL && count < max) { + while (*line == ' ' || *line == '\t') line++; + if (line[0] != '\0' && + (strncmp(line, "wss://", 6) == 0 || + strncmp(line, "ws://", 5) == 0)) { + urls_out[count++] = line; + } + line = strtok_r(NULL, "\n\r", &saveptr); + } + return count; +} + +/* Encrypt a plaintext string with NIP-44 (self-to-self). + * Returns a newly allocated string (caller must free), or NULL on error. */ +static char *encrypt_content(const char *plaintext) { + if (!g_have_signer || g_pubkey[0] == '\0') return NULL; + + char *ciphertext = NULL; + int rc = nostr_signer_nip44_encrypt(g_signer, g_pubkey, plaintext, + &ciphertext); + if (rc != NOSTR_SUCCESS || ciphertext == NULL) { + g_printerr("[agent_conv] NIP-44 encrypt failed: %d\n", rc); + return NULL; + } + return ciphertext; +} + +/* Decrypt a NIP-44 ciphertext (self-to-self). + * Returns a newly allocated string (caller must free), or NULL on error. */ +static char *decrypt_content(const char *ciphertext) { + if (!g_have_signer || g_pubkey[0] == '\0') return NULL; + if (ciphertext == NULL || ciphertext[0] == '\0') return NULL; + + char *plaintext = NULL; + int rc = nostr_signer_nip44_decrypt(g_signer, g_pubkey, ciphertext, + &plaintext); + if (rc != NOSTR_SUCCESS || plaintext == NULL) { + g_printerr("[agent_conv] NIP-44 decrypt failed: %d\n", rc); + return NULL; + } + return plaintext; +} + +/* Check whether an event has a tag [name, value]. Returns 1/0. */ +static int event_has_tag(const cJSON *event, const char *name, + const char *value) { + cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags"); + if (!cJSON_IsArray(tags)) return 0; + cJSON *tag; + cJSON_ArrayForEach(tag, tags) { + if (!cJSON_IsArray(tag)) continue; + cJSON *t0 = cJSON_GetArrayItem(tag, 0); + cJSON *t1 = cJSON_GetArrayItem(tag, 1); + if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 && + cJSON_IsString(t1) && strcmp(t1->valuestring, value) == 0) { + return 1; + } + } + return 0; +} + +/* Get the value of the first tag with the given name. Returns a pointer + * into the event's tags (or NULL). Not owned by caller. */ +static const char *event_tag_value(const cJSON *event, const char *name) { + cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags"); + if (!cJSON_IsArray(tags)) return NULL; + cJSON *tag; + cJSON_ArrayForEach(tag, tags) { + if (!cJSON_IsArray(tag)) continue; + cJSON *t0 = cJSON_GetArrayItem(tag, 0); + cJSON *t1 = cJSON_GetArrayItem(tag, 1); + if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 && + cJSON_IsString(t1)) { + return t1->valuestring; + } + } + return NULL; +} + +/* Build the full d-tag for a conversation id. With the empty prefix + * (AGENT_CONV_D_PREFIX ""), this is just a copy of the conversation id, + * matching ai.html's d:{conversation-id} format. Caller must g_free. */ +static char *build_d_tag(const char *conversation_id) { + return g_strdup_printf("%s%s", AGENT_CONV_D_PREFIX, conversation_id); +} + +/* Extract the conversation id from a d-tag value. With the empty prefix + * the id is the entire d-tag value. Returns NULL only if d_value is NULL. */ +static const char *d_tag_to_id(const char *d_value) { + size_t plen = strlen(AGENT_CONV_D_PREFIX); + if (d_value == NULL) return NULL; + if (strncmp(d_value, AGENT_CONV_D_PREFIX, plen) != 0) return NULL; + return d_value + plen; +} + +/* Derive a conversation title from the first user message in a message + * array. Returns a newly allocated string (caller must g_free), or + * g_strdup("New Chat") if no user message is found. */ +static char *derive_title(const cJSON *messages) { + if (!cJSON_IsArray(messages)) return g_strdup("New Chat"); + cJSON *msg; + cJSON_ArrayForEach(msg, messages) { + cJSON *role = cJSON_GetObjectItemCaseSensitive(msg, "role"); + if (cJSON_IsString(role) && strcmp(role->valuestring, "user") == 0) { + cJSON *content = cJSON_GetObjectItemCaseSensitive(msg, "content"); + if (cJSON_IsString(content) && content->valuestring[0]) { + /* Truncate to 60 chars, replace newlines with spaces. */ + char buf[64]; + size_t i = 0; + const char *s = content->valuestring; + while (s[i] && i < 60) { + buf[i] = (s[i] == '\n' || s[i] == '\r') ? ' ' : s[i]; + i++; + } + buf[i] = '\0'; + /* Trim trailing spaces. */ + while (i > 0 && buf[i - 1] == ' ') buf[--i] = '\0'; + if (i == 0) return g_strdup("New Chat"); + return g_strdup(buf); + } + } + } + return g_strdup("New Chat"); +} + +/* ── List ───────────────────────────────────────────────────────────── */ + +cJSON *agent_conversations_list(void) { + cJSON *result = cJSON_CreateArray(); + if (g_pubkey[0] == '\0') return result; + + /* Fetch all kind 30078 events for the user. We then filter by the + * t-tag. A generous limit covers all conversations. */ + cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512); + if (events == NULL) return result; + + /* Deduplicate by d-tag (conversation id), keeping only the latest + * event (highest created_at) for each conversation. Without this, + * re-saving or renaming a conversation leaves the old event in the + * SQLite cache, producing duplicate list entries with the same id — + * which causes two items to get the "active" highlight in the UI. */ + GHashTable *latest = g_hash_table_new_full(g_str_hash, g_str_equal, + g_free, (GDestroyNotify)cJSON_Delete); + + int n = cJSON_GetArraySize(events); + for (int i = 0; i < n; i++) { + cJSON *ev = cJSON_GetArrayItem(events, i); + + /* Must have the conversation t-tag. */ + if (!event_has_tag(ev, "t", AGENT_CONV_T_TAG)) continue; + + /* Extract the conversation id from the d-tag. */ + const char *d_val = event_tag_value(ev, "d"); + const char *conv_id = d_tag_to_id(d_val); + if (conv_id == NULL) continue; + + /* Extract created_at. */ + cJSON *created = cJSON_GetObjectItemCaseSensitive(ev, "created_at"); + long updated = 0; + if (cJSON_IsNumber(created)) updated = (long)created->valuedouble; + + /* Check if we already have a newer event for this conversation. */ + gpointer prev = g_hash_table_lookup(latest, conv_id); + if (prev != NULL) { + cJSON *prev_created = cJSON_GetObjectItemCaseSensitive( + (cJSON *)prev, "created_at"); + long prev_updated = 0; + if (cJSON_IsNumber(prev_created)) + prev_updated = (long)prev_created->valuedouble; + if (prev_updated >= updated) continue; /* keep the newer one */ + } + + /* Store a duplicate of this event as the latest for this id. */ + g_hash_table_replace(latest, g_strdup(conv_id), + cJSON_Duplicate(ev, 1)); + } + + /* Build the result array from the deduplicated hash table. */ + GHashTableIter iter; + gpointer key, value; + g_hash_table_iter_init(&iter, latest); + while (g_hash_table_iter_next(&iter, &key, &value)) { + const char *conv_id = (const char *)key; + cJSON *ev = (cJSON *)value; + + cJSON *created = cJSON_GetObjectItemCaseSensitive(ev, "created_at"); + long updated = 0; + if (cJSON_IsNumber(created)) updated = (long)created->valuedouble; + + /* Try to decrypt the content to get the title. If decryption + * fails (e.g. no signer in read-only mode), fall back to the + * conversation id. */ + char *title = NULL; + char *plaintext = decrypt_content( + cJSON_GetObjectItemCaseSensitive(ev, "content") && + cJSON_IsString(cJSON_GetObjectItemCaseSensitive(ev, "content")) + ? cJSON_GetObjectItemCaseSensitive(ev, "content")->valuestring + : ""); + if (plaintext) { + cJSON *payload = cJSON_Parse(plaintext); + free(plaintext); + if (payload) { + cJSON *t = cJSON_GetObjectItemCaseSensitive(payload, "title"); + if (cJSON_IsString(t) && t->valuestring[0]) { + title = g_strdup(t->valuestring); + } + cJSON_Delete(payload); + } + } + if (title == NULL) title = g_strdup(conv_id); + + cJSON *summary = cJSON_CreateObject(); + cJSON_AddStringToObject(summary, "id", conv_id); + cJSON_AddStringToObject(summary, "title", title); + cJSON_AddNumberToObject(summary, "updated_at", (double)updated); + cJSON_AddItemToArray(result, summary); + + g_free(title); + } + + g_hash_table_destroy(latest); + cJSON_Delete(events); + return result; +} + +/* ── Save ───────────────────────────────────────────────────────────── */ + +char *agent_conversations_save(const char *conversation_id, + const char *title) { + if (!g_have_signer || g_pubkey[0] == '\0') { + g_printerr("[agent_conv] No signer, cannot save\n"); + return NULL; + } + + /* Determine the conversation id. Generate one if not provided. */ + char *conv_id = NULL; + if (conversation_id && conversation_id[0]) { + conv_id = g_strdup(conversation_id); + } else { + conv_id = g_uuid_string_random(); + if (conv_id == NULL) { + g_printerr("[agent_conv] Failed to generate conversation id\n"); + return NULL; + } + } + + /* Fetch the current session's messages. */ + cJSON *messages = agent_chat_store_get_messages(); + if (messages == NULL) { + g_printerr("[agent_conv] Failed to get messages for save\n"); + g_free(conv_id); + return NULL; + } + + /* Determine the title: + * - If a title is provided, use it. + * - If no title but the conversation already exists on Nostr, + * preserve the existing title (don't overwrite a renamed title). + * - If no title and no existing conversation, derive from the + * first user message. */ + char *use_title = NULL; + if (title && title[0]) { + use_title = g_strdup(title); + } else { + /* Try to fetch the existing event's title. */ + char *existing_title = NULL; + char *d_tag_val = build_d_tag(conv_id); + if (d_tag_val && g_pubkey[0]) { + cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512); + if (events) { + int n = cJSON_GetArraySize(events); + for (int i = 0; i < n; i++) { + cJSON *ev = cJSON_GetArrayItem(events, i); + if (event_has_tag(ev, "d", d_tag_val)) { + cJSON *content_item = cJSON_GetObjectItemCaseSensitive(ev, "content"); + if (cJSON_IsString(content_item)) { + char *plaintext = decrypt_content(content_item->valuestring); + if (plaintext) { + cJSON *payload = cJSON_Parse(plaintext); + if (payload) { + cJSON *t = cJSON_GetObjectItemCaseSensitive(payload, "title"); + if (cJSON_IsString(t) && t->valuestring[0]) { + existing_title = g_strdup(t->valuestring); + } + cJSON_Delete(payload); + } + free(plaintext); + } + } + break; + } + } + cJSON_Delete(events); + } + } + g_free(d_tag_val); + if (existing_title) { + use_title = existing_title; + } else { + use_title = derive_title(messages); + } + } + + /* Build the payload: { schema: 1, title: "...", messages: [...] }. + * Only save user and assistant messages to Nostr — tool messages + * (tool call results) can be very large (e.g. entire web pages) + * and would bloat the kind 30078 event. They're kept in the local + * SQLite session for the agent loop's context but not synced. */ + cJSON *payload = cJSON_CreateObject(); + cJSON_AddNumberToObject(payload, "schema", 1); + cJSON_AddStringToObject(payload, "title", use_title); + cJSON *saved_msgs = cJSON_CreateArray(); + cJSON *msg; + cJSON_ArrayForEach(msg, messages) { + cJSON *role = cJSON_GetObjectItemCaseSensitive(msg, "role"); + const char *role_str = cJSON_IsString(role) ? role->valuestring : ""; + if (strcmp(role_str, "user") == 0 || strcmp(role_str, "assistant") == 0) { + cJSON_AddItemToArray(saved_msgs, cJSON_Duplicate(msg, 1)); + } + } + cJSON_AddItemToObject(payload, "messages", saved_msgs); + + char *json = cJSON_PrintUnformatted(payload); + cJSON_Delete(payload); + if (json == NULL) { + g_printerr("[agent_conv] Failed to serialize payload\n"); + cJSON_Delete(messages); + g_free(conv_id); + g_free(use_title); + return NULL; + } + + /* Encrypt. */ + char *ciphertext = encrypt_content(json); + free(json); + cJSON_Delete(messages); + if (ciphertext == NULL) { + g_free(conv_id); + g_free(use_title); + return NULL; + } + + /* Build tags: [["d", "{id}"], + * ["t", "client-ai-chat-v1"], + * ["client", "sovereign_browser"]] */ + cJSON *tags = cJSON_CreateArray(); + + char *d_tag_val = build_d_tag(conv_id); + cJSON *d_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(d_tag, cJSON_CreateString("d")); + cJSON_AddItemToArray(d_tag, cJSON_CreateString(d_tag_val)); + cJSON_AddItemToArray(tags, d_tag); + g_free(d_tag_val); + + cJSON *t_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(t_tag, cJSON_CreateString("t")); + cJSON_AddItemToArray(t_tag, cJSON_CreateString(AGENT_CONV_T_TAG)); + cJSON_AddItemToArray(tags, t_tag); + + cJSON *client_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(client_tag, cJSON_CreateString("client")); + cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser")); + cJSON_AddItemToArray(tags, client_tag); + + /* Create and sign the event. */ + cJSON *event = nostr_create_and_sign_event_with_signer( + AGENT_CONV_KIND, ciphertext, tags, g_signer, time(NULL)); + g_free(ciphertext); + + if (event == NULL) { + g_printerr("[agent_conv] Failed to create/sign event\n"); + cJSON_Delete(tags); + g_free(conv_id); + g_free(use_title); + return NULL; + } + + /* Store in SQLite. */ + db_store_event(event); + + /* Publish to bootstrap relays. */ + char *relay_buf = NULL; + const char *relay_urls[32]; + int relay_count = parse_relays(&relay_buf, relay_urls, 32); + + if (relay_count > 0) { + int success_count = 0; + publish_result_t *results = synchronous_publish_event_with_progress( + relay_urls, relay_count, event, &success_count, + 15, NULL, NULL, 0, NULL); + if (results) free(results); + g_print("[agent_conv] Saved conversation %s to %d/%d relays\n", + conv_id, success_count, relay_count); + } else { + g_print("[agent_conv] No relays configured, event stored locally\n"); + } + + g_free(relay_buf); + cJSON_Delete(event); + g_free(use_title); + return conv_id; +} + +/* ── Load ───────────────────────────────────────────────────────────── */ + +int agent_conversations_load(const char *conversation_id) { + if (conversation_id == NULL || conversation_id[0] == '\0') return -1; + if (g_pubkey[0] == '\0') return -1; + + /* Build the d-tag to look for. */ + char *d_tag_val = build_d_tag(conversation_id); + + /* Fetch all kind 30078 events and find the one with our d-tag. + * (db_get_latest_event doesn't filter by d-tag, so we scan.) */ + cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512); + if (events == NULL) { + g_free(d_tag_val); + return -1; + } + + cJSON *found = NULL; + int n = cJSON_GetArraySize(events); + for (int i = 0; i < n; i++) { + cJSON *ev = cJSON_GetArrayItem(events, i); + if (event_has_tag(ev, "d", d_tag_val)) { + found = cJSON_Duplicate(ev, 1); + break; + } + } + cJSON_Delete(events); + g_free(d_tag_val); + + if (found == NULL) { + g_printerr("[agent_conv] Conversation %s not found in cache\n", + conversation_id); + return -1; + } + + /* Decrypt the content. */ + cJSON *content_item = cJSON_GetObjectItemCaseSensitive(found, "content"); + if (!cJSON_IsString(content_item)) { + cJSON_Delete(found); + return -1; + } + char *plaintext = decrypt_content(content_item->valuestring); + cJSON_Delete(found); + if (plaintext == NULL) return -1; + + /* Parse the payload. */ + cJSON *payload = cJSON_Parse(plaintext); + free(plaintext); + if (payload == NULL) { + g_printerr("[agent_conv] Failed to parse decrypted payload\n"); + return -1; + } + + cJSON *title_item = cJSON_GetObjectItemCaseSensitive(payload, "title"); + const char *title = (cJSON_IsString(title_item)) + ? title_item->valuestring : "Loaded Chat"; + + cJSON *messages = cJSON_GetObjectItemCaseSensitive(payload, "messages"); + if (!cJSON_IsArray(messages)) { + g_printerr("[agent_conv] No messages array in payload\n"); + cJSON_Delete(payload); + return -1; + } + + /* Create a new agent chat session and populate it with the messages. */ + const char *new_sid = agent_chat_store_new_session(title); + if (new_sid == NULL) { + cJSON_Delete(payload); + return -1; + } + + /* Add each message to the new session. */ + cJSON *msg; + cJSON_ArrayForEach(msg, messages) { + cJSON *role_item = cJSON_GetObjectItemCaseSensitive(msg, "role"); + cJSON *content_m = cJSON_GetObjectItemCaseSensitive(msg, "content"); + cJSON *tc_item = cJSON_GetObjectItemCaseSensitive(msg, "tool_calls"); + cJSON *tcid_item = cJSON_GetObjectItemCaseSensitive(msg, "tool_call_id"); + + const char *role = (cJSON_IsString(role_item)) + ? role_item->valuestring : "user"; + const char *content = (cJSON_IsString(content_m)) + ? content_m->valuestring : NULL; + + /* Serialize tool_calls back to a string for storage. */ + char *tc_str = NULL; + if (tc_item && !cJSON_IsNull(tc_item)) { + tc_str = cJSON_PrintUnformatted(tc_item); + } + + const char *tcid = (cJSON_IsString(tcid_item) && + tcid_item->valuestring[0]) + ? tcid_item->valuestring : NULL; + + db_agent_message_add(new_sid, role, content, tc_str, tcid); + if (tc_str) free(tc_str); + } + + cJSON_Delete(payload); + return 0; +} + +/* ── Delete ─────────────────────────────────────────────────────────── */ + +int agent_conversations_delete(const char *conversation_id) { + if (conversation_id == NULL || conversation_id[0] == '\0') return -1; + if (g_pubkey[0] == '\0') return -1; + + /* Find the event id for this conversation. */ + char *d_tag_val = build_d_tag(conversation_id); + cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512); + if (events == NULL) { + g_free(d_tag_val); + return -1; + } + + char *event_id = NULL; + int n = cJSON_GetArraySize(events); + for (int i = 0; i < n; i++) { + cJSON *ev = cJSON_GetArrayItem(events, i); + if (event_has_tag(ev, "d", d_tag_val)) { + cJSON *id_item = cJSON_GetObjectItemCaseSensitive(ev, "id"); + if (cJSON_IsString(id_item)) { + event_id = g_strdup(id_item->valuestring); + } + break; + } + } + cJSON_Delete(events); + g_free(d_tag_val); + + if (event_id == NULL) { + g_printerr("[agent_conv] Conversation %s not found for delete\n", + conversation_id); + return -1; + } + + /* Publish a kind 5 deletion event referencing the conversation event. + * The content is an optional reason; tags list the event id to delete. */ + if (g_have_signer) { + cJSON *tags = cJSON_CreateArray(); + cJSON *e_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(e_tag, cJSON_CreateString("e")); + cJSON_AddItemToArray(e_tag, cJSON_CreateString(event_id)); + cJSON_AddItemToArray(tags, e_tag); + + cJSON *event = nostr_create_and_sign_event_with_signer( + 5, "Deleted conversation", tags, g_signer, time(NULL)); + cJSON_Delete(tags); + + if (event) { + db_store_event(event); + + char *relay_buf = NULL; + const char *relay_urls[32]; + int relay_count = parse_relays(&relay_buf, relay_urls, 32); + if (relay_count > 0) { + int success_count = 0; + publish_result_t *results = + synchronous_publish_event_with_progress( + relay_urls, relay_count, event, &success_count, + 15, NULL, NULL, 0, NULL); + if (results) free(results); + g_print("[agent_conv] Published deletion for %s to %d/%d relays\n", + conversation_id, success_count, relay_count); + } + g_free(relay_buf); + cJSON_Delete(event); + } + } + + /* Remove the kind 30078 event from the local SQLite cache so it + * no longer appears in conversation lists. The kind 5 tombstone + * published above handles relay-side deletion. */ + db_delete_event(event_id); + + g_free(event_id); + return 0; +} + +/* ── Rename ─────────────────────────────────────────────────────────── */ + +int agent_conversations_rename(const char *conversation_id, + const char *new_title) { + if (conversation_id == NULL || conversation_id[0] == '\0') return -1; + if (new_title == NULL || new_title[0] == '\0') return -1; + if (!g_have_signer || g_pubkey[0] == '\0') { + g_printerr("[agent_conv] No signer, cannot rename\n"); + return -1; + } + + /* Find the existing event for this conversation. */ + char *d_tag_val = build_d_tag(conversation_id); + cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512); + if (events == NULL) { + g_free(d_tag_val); + return -1; + } + + cJSON *found = NULL; + char *old_event_id = NULL; + int n = cJSON_GetArraySize(events); + for (int i = 0; i < n; i++) { + cJSON *ev = cJSON_GetArrayItem(events, i); + if (event_has_tag(ev, "d", d_tag_val)) { + found = cJSON_Duplicate(ev, 1); + /* Remember the old event's id so we can delete it from + * SQLite after storing the renamed event. This prevents + * duplicate events with the same d-tag. */ + cJSON *id_item = cJSON_GetObjectItemCaseSensitive(ev, "id"); + if (cJSON_IsString(id_item)) { + old_event_id = g_strdup(id_item->valuestring); + } + break; + } + } + cJSON_Delete(events); + g_free(d_tag_val); + + if (found == NULL) { + g_printerr("[agent_conv] Conversation %s not found for rename\n", + conversation_id); + g_free(old_event_id); + return -1; + } + + /* Decrypt the content. */ + cJSON *content_item = cJSON_GetObjectItemCaseSensitive(found, "content"); + if (!cJSON_IsString(content_item)) { + cJSON_Delete(found); + return -1; + } + char *plaintext = decrypt_content(content_item->valuestring); + cJSON_Delete(found); + if (plaintext == NULL) return -1; + + /* Parse the payload, replace the title, re-serialize. */ + cJSON *payload = cJSON_Parse(plaintext); + free(plaintext); + if (payload == NULL) { + g_printerr("[agent_conv] Failed to parse payload for rename\n"); + return -1; + } + + /* Replace the title field. */ + cJSON *title_item = cJSON_GetObjectItemCaseSensitive(payload, "title"); + if (title_item) { + cJSON_ReplaceItemInObjectCaseSensitive(payload, "title", + cJSON_CreateString(new_title)); + } else { + cJSON_AddStringToObject(payload, "title", new_title); + } + + char *json = cJSON_PrintUnformatted(payload); + cJSON_Delete(payload); + if (json == NULL) { + g_printerr("[agent_conv] Failed to serialize renamed payload\n"); + return -1; + } + + /* Re-encrypt. */ + char *ciphertext = encrypt_content(json); + free(json); + if (ciphertext == NULL) return -1; + + /* Build tags (same structure as save: d, t, client). */ + cJSON *tags = cJSON_CreateArray(); + + char *d_tag_val2 = build_d_tag(conversation_id); + cJSON *d_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(d_tag, cJSON_CreateString("d")); + cJSON_AddItemToArray(d_tag, cJSON_CreateString(d_tag_val2)); + cJSON_AddItemToArray(tags, d_tag); + g_free(d_tag_val2); + + cJSON *t_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(t_tag, cJSON_CreateString("t")); + cJSON_AddItemToArray(t_tag, cJSON_CreateString(AGENT_CONV_T_TAG)); + cJSON_AddItemToArray(tags, t_tag); + + cJSON *client_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(client_tag, cJSON_CreateString("client")); + cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser")); + cJSON_AddItemToArray(tags, client_tag); + + /* Create and sign the new event. */ + cJSON *event = nostr_create_and_sign_event_with_signer( + AGENT_CONV_KIND, ciphertext, tags, g_signer, time(NULL)); + g_free(ciphertext); + + if (event == NULL) { + g_printerr("[agent_conv] Failed to create/sign rename event\n"); + cJSON_Delete(tags); + return -1; + } + + /* Store the renamed event in SQLite. */ + db_store_event(event); + + /* Delete the old event from SQLite so there's only one event per + * d-tag. Without this, both the old and new events exist, and the + * dedup logic in agent_conversations_list() might pick the wrong + * one if the old event has a higher created_at. */ + if (old_event_id) { + db_delete_event(old_event_id); + g_free(old_event_id); + old_event_id = NULL; + } + + /* Publish to bootstrap relays. */ + char *relay_buf = NULL; + const char *relay_urls[32]; + int relay_count = parse_relays(&relay_buf, relay_urls, 32); + + if (relay_count > 0) { + int success_count = 0; + publish_result_t *results = synchronous_publish_event_with_progress( + relay_urls, relay_count, event, &success_count, + 15, NULL, NULL, 0, NULL); + if (results) free(results); + g_print("[agent_conv] Renamed conversation %s to %d/%d relays\n", + conversation_id, success_count, relay_count); + } else { + g_print("[agent_conv] No relays configured, rename stored locally\n"); + } + + g_free(relay_buf); + cJSON_Delete(event); + return 0; +} diff --git a/src/agent_conversations.h b/src/agent_conversations.h new file mode 100644 index 0000000..a517d26 --- /dev/null +++ b/src/agent_conversations.h @@ -0,0 +1,144 @@ +/* + * agent_conversations.h — Nostr conversation persistence for agent chat + * + * Saves and loads agent chat conversations as NIP-78 (kind 30078) addressable + * events. Each conversation is stored with: + * d-tag: "{uuid}" (no prefix — matches ai.html so conversations are + * shared between sovereign_browser and the client) + * t-tag: "client-ai-chat-v1" + * + * The event content is NIP-44 encrypted (self-to-self) JSON: + * { "schema": 1, "title": "...", "messages": [ {role, content, tool_calls, tool_call_id}, ... ] } + * + * Reuses the NIP-44 encryption + event signing + relay publishing patterns + * from settings_sync.c and bookmarks.c. + * + * See plans/cross-project-agent-sync.md (Workstream 3) for the full design. + */ + +#ifndef AGENT_CONVERSATIONS_H +#define AGENT_CONVERSATIONS_H + +#include + +/* cJSON is in the vendored nostr_core_lib */ +#include "../nostr_core_lib/cjson/cJSON.h" + +/* nostr_signer_t is needed for init */ +#include "nostr_core/nostr_signer.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* The Nostr kind for arbitrary custom app data (NIP-78). */ +#define AGENT_CONV_KIND 30078 + +/* The t-tag that identifies these conversation events. Matches ai.html + * so conversations are shared between sovereign_browser and the client. */ +#define AGENT_CONV_T_TAG "client-ai-chat-v1" + +/* The prefix for the d-tag. Empty — the d-tag is just the UUID, matching + * ai.html's d:{conversation-id} format. Kept as a macro for compatibility + * with build_d_tag()/d_tag_to_id(). */ +#define AGENT_CONV_D_PREFIX "" + +/* + * Initialize the conversation persistence module. Stores the signer + pubkey + * references for save/load/delete operations. + * + * signer — the user's nostr_signer_t (NULL for read-only/no-login) + * pubkey_hex — the user's hex pubkey (64 chars, may be NULL) + * + * Call after login. + */ +void agent_conversations_init(nostr_signer_t *signer, + const char *pubkey_hex); + +/* + * Update the signer reference (e.g. after switching identity). + */ +void agent_conversations_set_signer(nostr_signer_t *signer, + const char *pubkey_hex); + +/* + * List all saved conversations from the local SQLite cache. + * + * Queries kind 30078 events by the user's pubkey that have the + * t-tag "client-ai-chat-v1". Extracts the conversation id + * (from the d-tag, which is just the UUID), title (from the + * decrypted content), and updated_at (from created_at). + * + * Returns a newly allocated cJSON array of summary objects: + * [{"id":"...", "title":"...", "updated_at":N}, ...] + * Returns an empty array on error or if no conversations exist. + * Caller must cJSON_Delete() the result. + */ +cJSON *agent_conversations_list(void); + +/* + * Save the current agent chat session to Nostr as a kind 30078 event. + * + * conversation_id — the conversation id (UUID). If NULL, a new UUID is + * generated and the d-tag becomes just "{uuid}" + * (no prefix, matching ai.html). + * title — the conversation title. If NULL, derived from the + * first user message (truncated to 60 chars). + * + * Fetches the current session's messages via agent_chat_store, NIP-44 + * encrypts them, builds a kind 30078 event with the d-tag and t-tag, + * signs it, publishes to bootstrap relays, and stores in SQLite. + * + * Returns a newly allocated string with the conversation id (caller must + * g_free), or NULL on error. + */ +char *agent_conversations_save(const char *conversation_id, + const char *title); + +/* + * Load a conversation from Nostr (local SQLite cache) into the agent chat + * store as the current session. + * + * conversation_id — the conversation id (the UUID used as the d-tag). + * + * Fetches the kind 30078 event with d-tag "{id}", NIP-44 decrypts + * the content, parses the messages, creates a new agent chat session, + * and populates it with the parsed messages. + * + * Returns 0 on success, -1 on error. + */ +int agent_conversations_load(const char *conversation_id); + +/* + * Rename a conversation — update its title in the local SQLite cache + * and re-publish the kind 30078 event with the new title. + * + * conversation_id — the conversation id (the UUID used as the d-tag). + * new_title — the new title (must be non-NULL and non-empty). + * + * Loads the existing event, decrypts the content, replaces the title, + * re-encrypts, signs a new kind 30078 event with the same d-tag, stores + * it in SQLite, and publishes to bootstrap relays. + * + * Returns 0 on success, -1 on error. + */ +int agent_conversations_rename(const char *conversation_id, + const char *new_title); + +/* + * Delete a conversation from Nostr. + * + * conversation_id — the conversation id (the UUID used as the d-tag). + * + * Publishes a kind 5 deletion event referencing the kind 30078 event id, + * and removes the event from the local SQLite cache. + * + * Returns 0 on success, -1 on error. + */ +int agent_conversations_delete(const char *conversation_id); + +#ifdef __cplusplus +} +#endif + +#endif /* AGENT_CONVERSATIONS_H */ diff --git a/src/agent_fs_tools.c b/src/agent_fs_tools.c new file mode 100644 index 0000000..5994d56 --- /dev/null +++ b/src/agent_fs_tools.c @@ -0,0 +1,488 @@ +/* + * agent_fs_tools.c — filesystem & shell tool implementations + * + * Tools: + * fs_read — read a file's text contents + * fs_write — write text to a file (overwrite or create) + * fs_list — list directory entries + * fs_mkdir — create a directory recursively + * fs_delete — delete a file or empty directory + * shell_exec — run a shell command, return stdout+stderr+exit code + * + * The browser runs in a dedicated Qubes OS qube, so full filesystem + * and shell access is intended. These tools work before login. + */ + +#include "agent_fs_tools.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +/* ── JSON helpers (same pattern as agent_tools.c) ──────────────────── */ + +static cJSON *make_success(cJSON *data) { + cJSON *resp = cJSON_CreateObject(); + cJSON_AddBoolToObject(resp, "success", TRUE); + if (data) { + cJSON_AddItemToObject(resp, "data", data); + } + return resp; +} + +static cJSON *make_error(const char *code, const char *message) { + cJSON *resp = cJSON_CreateObject(); + cJSON_AddBoolToObject(resp, "success", FALSE); + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "code", code); + cJSON_AddStringToObject(err, "message", message); + cJSON_AddItemToObject(resp, "error", err); + return resp; +} + +static const char *get_string_param(cJSON *params, const char *key) { + return cJSON_GetStringValue(cJSON_GetObjectItem(params, key)); +} + +static int get_int_param(cJSON *params, const char *key, int fallback) { + cJSON *item = cJSON_GetObjectItem(params, key); + if (item && cJSON_IsNumber(item)) return item->valueint; + return fallback; +} + +/* ── fs_read ───────────────────────────────────────────────────────── */ + +static cJSON *tool_fs_read(cJSON *params) { + const char *path = get_string_param(params, "path"); + if (!path || !path[0]) { + return make_error("MISSING_PARAM", "Provide 'path'"); + } + + /* Stat the file first to get the size and verify it's a regular file. */ + struct stat st; + if (stat(path, &st) != 0) { + return make_error("FILE_NOT_FOUND", + g_strdup_printf("Cannot stat '%s': %s", path, strerror(errno))); + } + if (!S_ISREG(st.st_mode)) { + return make_error("NOT_A_FILE", + g_strdup_printf("'%s' is not a regular file", path)); + } + + /* Cap reads at 16 MB to avoid exhausting memory on huge files. */ + off_t size = st.st_size; + if (size > (16 * 1024 * 1024)) { + return make_error("FILE_TOO_LARGE", + g_strdup_printf("'%s' is %lld bytes; max read is 16 MB", + path, (long long)size)); + } + + int fd = open(path, O_RDONLY); + if (fd < 0) { + return make_error("OPEN_FAILED", + g_strdup_printf("Cannot open '%s': %s", path, strerror(errno))); + } + + char *buf = g_malloc(size + 1); + if (buf == NULL) { + close(fd); + return make_error("OUT_OF_MEMORY", "Cannot allocate read buffer"); + } + + off_t total = 0; + while (total < size) { + ssize_t n = read(fd, buf + total, size - total); + if (n < 0) { + if (errno == EINTR) continue; + g_free(buf); + close(fd); + return make_error("READ_FAILED", + g_strdup_printf("Read error on '%s': %s", path, strerror(errno))); + } + if (n == 0) break; /* EOF (file shrank?) */ + total += n; + } + buf[total] = '\0'; + close(fd); + + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "content", buf); + g_free(buf); + return make_success(data); +} + +/* ── fs_write ──────────────────────────────────────────────────────── */ + +static cJSON *tool_fs_write(cJSON *params) { + const char *path = get_string_param(params, "path"); + if (!path || !path[0]) { + return make_error("MISSING_PARAM", "Provide 'path'"); + } + const char *content = get_string_param(params, "content"); + if (content == NULL) { + /* Allow NULL to be treated as empty string. */ + content = ""; + } + + /* Create parent directories if needed so the write succeeds. */ + char *parent = g_path_get_dirname(path); + if (parent && parent[0] && strcmp(parent, ".") != 0) { + g_mkdir_with_parents(parent, 0755); + } + g_free(parent); + + int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd < 0) { + return make_error("OPEN_FAILED", + g_strdup_printf("Cannot open '%s' for writing: %s", + path, strerror(errno))); + } + + size_t len = strlen(content); + size_t total = 0; + while (total < len) { + ssize_t n = write(fd, content + total, len - total); + if (n < 0) { + if (errno == EINTR) continue; + close(fd); + return make_error("WRITE_FAILED", + g_strdup_printf("Write error on '%s': %s", path, strerror(errno))); + } + total += (size_t)n; + } + close(fd); + + cJSON *data = cJSON_CreateObject(); + cJSON_AddNumberToObject(data, "bytes_written", (double)total); + return make_success(data); +} + +/* ── fs_list ───────────────────────────────────────────────────────── */ + +static cJSON *tool_fs_list(cJSON *params) { + const char *path = get_string_param(params, "path"); + if (!path || !path[0]) { + return make_error("MISSING_PARAM", "Provide 'path'"); + } + + DIR *dir = opendir(path); + if (dir == NULL) { + return make_error("OPEN_FAILED", + g_strdup_printf("Cannot open directory '%s': %s", + path, strerror(errno))); + } + + cJSON *entries = cJSON_CreateArray(); + struct dirent *ent; + while ((ent = readdir(dir)) != NULL) { + /* Skip "." and ".." — they're not useful for an agent. */ + if (strcmp(ent->d_name, ".") == 0 || strcmp(ent->d_name, "..") == 0) { + continue; + } + + /* Build the full path to stat the entry. */ + char *full = g_strdup_printf("%s/%s", path, ent->d_name); + struct stat st; + const char *type = "file"; + long long size = 0; + if (stat(full, &st) == 0) { + if (S_ISDIR(st.st_mode)) { + type = "dir"; + } else if (S_ISLNK(st.st_mode)) { + type = "link"; + } else if (!S_ISREG(st.st_mode)) { + type = "other"; + } + size = (long long)st.st_size; + } + g_free(full); + + cJSON *entry = cJSON_CreateObject(); + cJSON_AddStringToObject(entry, "name", ent->d_name); + cJSON_AddStringToObject(entry, "type", type); + cJSON_AddNumberToObject(entry, "size", (double)size); + cJSON_AddItemToArray(entries, entry); + } + closedir(dir); + + cJSON *data = cJSON_CreateObject(); + cJSON_AddItemToObject(data, "entries", entries); + return make_success(data); +} + +/* ── fs_mkdir ──────────────────────────────────────────────────────── */ + +static cJSON *tool_fs_mkdir(cJSON *params) { + const char *path = get_string_param(params, "path"); + if (!path || !path[0]) { + return make_error("MISSING_PARAM", "Provide 'path'"); + } + + if (g_mkdir_with_parents(path, 0755) != 0) { + return make_error("MKDIR_FAILED", + g_strdup_printf("Cannot create directory '%s': %s", + path, strerror(errno))); + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddBoolToObject(data, "created", TRUE); + return make_success(data); +} + +/* ── fs_delete ─────────────────────────────────────────────────────── */ + +static cJSON *tool_fs_delete(cJSON *params) { + const char *path = get_string_param(params, "path"); + if (!path || !path[0]) { + return make_error("MISSING_PARAM", "Provide 'path'"); + } + + struct stat st; + if (stat(path, &st) != 0) { + return make_error("FILE_NOT_FOUND", + g_strdup_printf("'%s' does not exist: %s", path, strerror(errno))); + } + + int rc; + if (S_ISDIR(st.st_mode)) { + rc = rmdir(path); + } else { + rc = unlink(path); + } + + if (rc != 0) { + return make_error("DELETE_FAILED", + g_strdup_printf("Cannot delete '%s': %s", path, strerror(errno))); + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddBoolToObject(data, "deleted", TRUE); + return make_success(data); +} + +/* ── shell_exec ────────────────────────────────────────────────────── * + * + * Runs a shell command via fork() + execvp("/bin/sh", "-c", command), + * capturing stdout and stderr through separate pipes. A timeout is + * enforced with a waitpid() polling loop; if the deadline is exceeded + * the child is killed with SIGKILL and we report exit_code -1. + */ + +static cJSON *tool_shell_exec(cJSON *params) { + const char *command = get_string_param(params, "command"); + if (!command || !command[0]) { + return make_error("MISSING_PARAM", "Provide 'command'"); + } + int timeout_ms = get_int_param(params, "timeout_ms", 30000); + if (timeout_ms < 0) timeout_ms = 30000; + + /* Create pipes for stdout and stderr. */ + int out_pipe[2]; + int err_pipe[2]; + if (pipe(out_pipe) != 0) { + return make_error("PIPE_FAILED", + g_strdup_printf("pipe() failed: %s", strerror(errno))); + } + if (pipe(err_pipe) != 0) { + close(out_pipe[0]); + close(out_pipe[1]); + return make_error("PIPE_FAILED", + g_strdup_printf("pipe() failed: %s", strerror(errno))); + } + + pid_t pid = fork(); + if (pid < 0) { + close(out_pipe[0]); close(out_pipe[1]); + close(err_pipe[0]); close(err_pipe[1]); + return make_error("FORK_FAILED", + g_strdup_printf("fork() failed: %s", strerror(errno))); + } + + if (pid == 0) { + /* ── Child ──────────────────────────────────────────────── */ + /* Redirect stdout and stderr to the pipes. */ + dup2(out_pipe[1], STDOUT_FILENO); + dup2(err_pipe[1], STDERR_FILENO); + close(out_pipe[0]); close(out_pipe[1]); + close(err_pipe[0]); close(err_pipe[1]); + + /* Run the command via /bin/sh -c. */ + char *const argv[] = {"sh", "-c", (char *)command, NULL}; + execvp("/bin/sh", argv); + /* If execvp returns, it failed. */ + fprintf(stderr, "execvp failed: %s\n", strerror(errno)); + _exit(127); + } + + /* ── Parent ──────────────────────────────────────────────────── */ + close(out_pipe[1]); + close(err_pipe[1]); + + /* Read stdout and stderr fully (non-blocking with poll). */ + GString *out_buf = g_string_new(NULL); + GString *err_buf = g_string_new(NULL); + + /* Set both pipe read ends to non-blocking. */ + fcntl(out_pipe[0], F_SETFL, O_NONBLOCK); + fcntl(err_pipe[0], F_SETFL, O_NONBLOCK); + + long deadline_ms = (long)(g_get_monotonic_time() / 1000) + (long)timeout_ms; + int exit_code = -1; + gboolean timed_out = FALSE; + gboolean child_done = FALSE; + + char chunk[4096]; + while (!child_done) { + /* Check timeout. */ + long now_ms = (long)(g_get_monotonic_time() / 1000); + if (now_ms >= deadline_ms) { + timed_out = TRUE; + kill(pid, SIGKILL); + break; + } + + /* Poll the pipes for up to 50ms, then check the child. */ + struct pollfd pfds[2]; + int nfds = 0; + pfds[nfds].fd = out_pipe[0]; + pfds[nfds].events = POLLIN; + nfds++; + pfds[nfds].fd = err_pipe[0]; + pfds[nfds].events = POLLIN; + nfds++; + + int pr = poll(pfds, (nfds_t)nfds, 50); + if (pr > 0) { + for (int i = 0; i < nfds; i++) { + if (pfds[i].revents & POLLIN) { + ssize_t n; + while ((n = read(pfds[i].fd, chunk, sizeof(chunk))) > 0) { + if (pfds[i].fd == out_pipe[0]) { + g_string_append_len(out_buf, chunk, n); + } else { + g_string_append_len(err_buf, chunk, n); + } + } + } + if (pfds[i].revents & (POLLHUP | POLLERR)) { + /* Drain remaining, then mark closed. */ + ssize_t n; + while ((n = read(pfds[i].fd, chunk, sizeof(chunk))) > 0) { + if (pfds[i].fd == out_pipe[0]) { + g_string_append_len(out_buf, chunk, n); + } else { + g_string_append_len(err_buf, chunk, n); + } + } + } + } + } + + /* Check if the child has exited. */ + int status; + pid_t wr = waitpid(pid, &status, WNOHANG); + if (wr == pid) { + child_done = TRUE; + if (WIFEXITED(status)) { + exit_code = WEXITSTATUS(status); + } else if (WIFSIGNALED(status)) { + exit_code = 128 + WTERMSIG(status); + } else { + exit_code = -1; + } + } else if (wr < 0) { + if (errno == EINTR) continue; + /* waitpid error — treat as done. */ + child_done = TRUE; + exit_code = -1; + } + } + + /* If we broke out due to timeout, reap the child. */ + if (timed_out) { + int status; + /* Give it a moment to die from SIGKILL. */ + for (int i = 0; i < 10; i++) { + pid_t wr = waitpid(pid, &status, WNOHANG); + if (wr == pid) break; + if (wr < 0 && errno != EINTR) break; + struct timespec ts = {0, 10 * 1000 * 1000}; /* 10ms */ + nanosleep(&ts, NULL); + } + /* Final blocking reap. */ + waitpid(pid, &status, 0); + exit_code = -1; + } + + /* Drain any remaining data from the pipes after the child exited. */ + ssize_t n; + while ((n = read(out_pipe[0], chunk, sizeof(chunk))) > 0) { + g_string_append_len(out_buf, chunk, n); + } + while ((n = read(err_pipe[0], chunk, sizeof(chunk))) > 0) { + g_string_append_len(err_buf, chunk, n); + } + + close(out_pipe[0]); + close(err_pipe[0]); + + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "stdout", out_buf->str); + cJSON_AddStringToObject(data, "stderr", err_buf->str); + cJSON_AddNumberToObject(data, "exit_code", exit_code); + if (timed_out) { + cJSON_AddBoolToObject(data, "timed_out", TRUE); + } + g_string_free(out_buf, TRUE); + g_string_free(err_buf, TRUE); + return make_success(data); +} + +/* ── Dispatch ──────────────────────────────────────────────────────── */ + +int agent_fs_is_tool(const char *tool_name) { + if (tool_name == NULL) return 0; + return (strcmp(tool_name, "fs_read") == 0 || + strcmp(tool_name, "fs_write") == 0 || + strcmp(tool_name, "fs_list") == 0 || + strcmp(tool_name, "fs_mkdir") == 0 || + strcmp(tool_name, "fs_delete") == 0 || + strcmp(tool_name, "shell_exec") == 0); +} + +cJSON *agent_fs_tools_dispatch(const char *tool_name, cJSON *params) { + if (tool_name == NULL) return NULL; + if (params == NULL) params = cJSON_CreateObject(); + + if (strcmp(tool_name, "fs_read") == 0) { + return tool_fs_read(params); + } + if (strcmp(tool_name, "fs_write") == 0) { + return tool_fs_write(params); + } + if (strcmp(tool_name, "fs_list") == 0) { + return tool_fs_list(params); + } + if (strcmp(tool_name, "fs_mkdir") == 0) { + return tool_fs_mkdir(params); + } + if (strcmp(tool_name, "fs_delete") == 0) { + return tool_fs_delete(params); + } + if (strcmp(tool_name, "shell_exec") == 0) { + return tool_shell_exec(params); + } + return NULL; /* not an fs/shell tool */ +} diff --git a/src/agent_fs_tools.h b/src/agent_fs_tools.h new file mode 100644 index 0000000..21d9e7b --- /dev/null +++ b/src/agent_fs_tools.h @@ -0,0 +1,42 @@ +/* + * agent_fs_tools.h — filesystem & shell tools for the embedded agent + * + * Provides tools that give the agent direct filesystem and shell access + * within the browser's qube. These tools work before login (they are + * system-level, not browser-level) and are dispatched early in + * agent_tools_dispatch(). + */ + +#ifndef AGENT_FS_TOOLS_H +#define AGENT_FS_TOOLS_H + +#include "cjson/cJSON.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Dispatch a filesystem/shell tool request. + * + * tool_name: one of "fs_read", "fs_write", "fs_list", "fs_mkdir", + * "fs_delete", "shell_exec" + * params: cJSON object with the tool's parameters + * + * Returns a cJSON response in the same format as agent_tools_dispatch(): + * {"success":true,"data":{...}} or + * {"success":false,"error":{"code":"...","message":"..."}} + * + * The caller frees the returned cJSON*. Returns NULL if tool_name is + * not recognized (so the caller can fall through to other handlers). + */ +cJSON *agent_fs_tools_dispatch(const char *tool_name, cJSON *params); + +/* Returns TRUE if the given tool name is handled by this module. */ +int agent_fs_is_tool(const char *tool_name); + +#ifdef __cplusplus +} +#endif + +#endif /* AGENT_FS_TOOLS_H */ diff --git a/src/agent_llm.c b/src/agent_llm.c new file mode 100644 index 0000000..524eb56 --- /dev/null +++ b/src/agent_llm.c @@ -0,0 +1,417 @@ +/* + * agent_llm.c — OpenAI-compatible LLM HTTP client + * + * Uses libsoup-3.0's synchronous soup_session_send_and_read() to POST + * a chat-completions request to an OpenAI-compatible endpoint, then + * parses the JSON response with cJSON and returns the assistant's + * message (text content + any tool_calls). + * + * A fresh SoupSession is created per call so this is safe to invoke + * from a background thread (libsoup-3.0 sessions are not meant to be + * shared across threads). The GTK main thread is never touched here. + */ + +#include "agent_llm.h" +#include "agent_tool_catalog.h" + +#include +#include +#include +#include +#include + +/* ── OpenAI tools helper ────────────────────────────────────────────── * + * + * build_tools_list() returns the catalog in MCP format: + * [{"name":...,"description":...,"inputSchema":{...}}, ...] + * + * OpenAI wants each entry wrapped as: + * {"type":"function","function":{"name":...,"description":...,"parameters":{...}}} + * + * We rebuild from tool_defs[] directly so the "parameters" field is a + * fresh cJSON copy (the MCP version uses "inputSchema"). + */ +cJSON *agent_llm_build_openai_tools(void) { + cJSON *tools = cJSON_CreateArray(); + if (tools == NULL) { + return NULL; + } + + for (int i = 0; i < tool_defs_count; i++) { + cJSON *entry = cJSON_CreateObject(); + if (entry == NULL) { + cJSON_Delete(tools); + return NULL; + } + + cJSON_AddStringToObject(entry, "type", "function"); + + cJSON *fn = cJSON_CreateObject(); + if (fn == NULL) { + cJSON_Delete(entry); + cJSON_Delete(tools); + return NULL; + } + cJSON_AddStringToObject(fn, "name", tool_defs[i].name); + cJSON_AddStringToObject(fn, "description", tool_defs[i].description); + + cJSON *schema = cJSON_Parse(tool_defs[i].schema_json); + if (schema) { + cJSON_AddItemToObject(fn, "parameters", schema); + } else { + /* Fall back to an empty object so the field is always present. */ + cJSON_AddItemToObject(fn, "parameters", cJSON_CreateObject()); + } + + cJSON_AddItemToObject(entry, "function", fn); + cJSON_AddItemToArray(tools, entry); + } + + return tools; +} + +/* ── Response lifecycle ─────────────────────────────────────────────── */ + +void agent_llm_response_free(agent_llm_response_t *resp) { + if (resp == NULL) { + return; + } + g_free(resp->content); + if (resp->tool_calls) { + cJSON_Delete(resp->tool_calls); + } + g_free(resp->finish_reason); + g_free(resp); +} + +/* ── Internal: build the request body JSON ──────────────────────────── * + * + * Returns a newly-allocated JSON string (caller frees with g_free). + * The caller's messages/tools arrays are referenced (not consumed) — + * cJSON_AddItemReferenceToObject increments the refcount so the caller + * retains ownership. + * + * Returns NULL on allocation failure. + */ +static char *build_request_body(const char *model, + cJSON *messages, + cJSON *tools) { + cJSON *root = cJSON_CreateObject(); + if (root == NULL) { + return NULL; + } + + cJSON_AddStringToObject(root, "model", model); + + /* Reference the caller's array so we don't steal ownership. */ + cJSON_AddItemReferenceToObject(root, "messages", messages); + + if (tools != NULL) { + cJSON_AddItemReferenceToObject(root, "tools", tools); + cJSON_AddStringToObject(root, "tool_choice", "auto"); + } + + char *str = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + return str; +} + +/* ── Internal: parse the response body into agent_llm_response_t ────── * + * + * body is the raw HTTP response body (NUL-terminated by caller). + * Returns a newly-allocated agent_llm_response_t, or NULL on parse + * failure / error response. + */ +static agent_llm_response_t *parse_response(const char *body) { + cJSON *root = cJSON_Parse(body); + if (root == NULL) { + g_printerr("[agent_llm] failed to parse response JSON\n"); + return NULL; + } + + /* Some servers return {"error": {...}} on failure. */ + cJSON *err = cJSON_GetObjectItemCaseSensitive(root, "error"); + if (err) { + char *err_str = cJSON_PrintUnformatted(err); + g_printerr("[agent_llm] API error: %s\n", + err_str ? err_str : "(unknown)"); + cJSON_free(err_str); + cJSON_Delete(root); + return NULL; + } + + cJSON *choices = cJSON_GetObjectItemCaseSensitive(root, "choices"); + cJSON *choice0 = cJSON_GetArrayItem(choices, 0); + if (choice0 == NULL) { + g_printerr("[agent_llm] response has no choices[0]\n"); + cJSON_Delete(root); + return NULL; + } + + agent_llm_response_t *resp = g_new0(agent_llm_response_t, 1); + if (resp == NULL) { + cJSON_Delete(root); + return NULL; + } + + cJSON *message = cJSON_GetObjectItemCaseSensitive(choice0, "message"); + if (message) { + cJSON *content = cJSON_GetObjectItemCaseSensitive(message, "content"); + if (cJSON_IsString(content) && content->valuestring != NULL) { + resp->content = g_strdup(content->valuestring); + } else { + resp->content = NULL; + } + + cJSON *tool_calls = cJSON_GetObjectItemCaseSensitive(message, "tool_calls"); + if (tool_calls != NULL) { + /* Detach a deep copy so the response outlives the parsed root. */ + resp->tool_calls = cJSON_Duplicate(tool_calls, 1); + } else { + resp->tool_calls = NULL; + } + } + + cJSON *finish = cJSON_GetObjectItemCaseSensitive(choice0, "finish_reason"); + if (cJSON_IsString(finish) && finish->valuestring != NULL) { + resp->finish_reason = g_strdup(finish->valuestring); + } else { + resp->finish_reason = NULL; + } + + cJSON_Delete(root); + return resp; +} + +/* ── Internal: normalize base URL ───────────────────────────────────── * + * + * Many OpenAI-compatible APIs expect the path prefix "/v1" before the + * endpoint (e.g. https://api.openai.com/v1/models). If the user supplies + * a base URL that already ends with "/v1" (or another "/vN" version + * segment) we leave it alone; otherwise we append "/v1" so that + * {base}/models and {base}/chat/completions resolve correctly. + * + * Returns a newly-allocated string (caller frees with g_free). + */ +static char *normalize_base_url(const char *base_url) { + if (base_url == NULL) { + return NULL; + } + + /* Strip trailing slashes for consistent checking. */ + g_autofree char *trimmed = NULL; + { + size_t len = strlen(base_url); + while (len > 0 && base_url[len - 1] == '/') { + len--; + } + trimmed = g_strndup(base_url, len); + } + + size_t tlen = strlen(trimmed); + + /* Already ends with "/v1"? */ + if (tlen >= 3 && strcmp(trimmed + tlen - 3, "/v1") == 0) { + return g_strdup(trimmed); + } + + /* Also handle "/v2", "/v3" etc. — if the last path segment is + * "v" followed by one or more digits, assume the user already + * included a version prefix. */ + if (tlen > 0) { + const char *slash = strrchr(trimmed, '/'); + if (slash != NULL) { + const char *seg = slash + 1; + if (seg[0] == 'v' && seg[1] >= '0' && seg[1] <= '9') { + return g_strdup(trimmed); + } + } + } + + return g_strdup_printf("%s/v1", trimmed); +} + +/* ── Public: agent_llm_chat ─────────────────────────────────────────── */ + +agent_llm_response_t *agent_llm_chat(const char *base_url, + const char *api_key, + const char *model, + cJSON *messages, + cJSON *tools) { + if (base_url == NULL || model == NULL || messages == NULL) { + return NULL; + } + + /* Build the full URL: {normalized_base_url}/chat/completions. + * normalize_base_url() strips trailing slashes and appends "/v1" + * if needed, so we never produce a "//" here. */ + g_autofree char *norm = normalize_base_url(base_url); + g_autofree char *url = g_strdup_printf("%s/chat/completions", norm); + + /* Build request body. */ + g_autofree char *body = build_request_body(model, messages, tools); + if (body == NULL) { + g_printerr("[agent_llm] failed to build request body\n"); + return NULL; + } + + /* Create the SoupMessage. */ + SoupMessage *msg = soup_message_new("POST", url); + if (msg == NULL) { + g_printerr("[agent_llm] invalid URL: %s\n", url); + return NULL; + } + + soup_message_headers_set_content_type( + soup_message_get_request_headers(msg), "application/json", NULL); + + if (api_key != NULL && api_key[0] != '\0') { + g_autofree char *bearer = g_strdup_printf("Bearer %s", api_key); + soup_message_headers_append( + soup_message_get_request_headers(msg), "Authorization", bearer); + } + + /* Set the request body from a GBytes. libsoup-3.0 takes ownership + * of the GBytes; the g_autofree `body` string is freed at scope exit. */ + GBytes *req_bytes = g_bytes_new(body, strlen(body)); + soup_message_set_request_body_from_bytes(msg, "application/json", req_bytes); + g_bytes_unref(req_bytes); + + /* Send synchronously. A fresh session per call keeps things + * thread-safe without any global state. */ + g_print("[agent_llm] POST %s (model=%s, %d messages, %d tools)\n", + url, model, cJSON_GetArraySize(messages), + tools ? cJSON_GetArraySize(tools) : 0); + SoupSession *session = soup_session_new(); + /* Set a generous timeout (300s) to accommodate slow CPU-only models + * and cold-start loading. The default libsoup timeout is 60s, which + * is too short for large local models. */ + g_object_set(session, "timeout", 300, NULL); + GError *error = NULL; + GBytes *resp_bytes = soup_session_send_and_read(session, msg, NULL, &error); + + agent_llm_response_t *result = NULL; + + if (error != NULL) { + g_printerr("[agent_llm] HTTP transport error: %s\n", + error->message); + g_error_free(error); + } else { + guint status = soup_message_get_status(msg); + if (status != SOUP_STATUS_OK) { + gsize size = 0; + const gchar *data = g_bytes_get_data(resp_bytes, &size); + g_printerr("[agent_llm] HTTP %u: %.*s\n", + status, (int)size, data ? data : ""); + } else { + gsize size = 0; + const gchar *data = g_bytes_get_data(resp_bytes, &size); + /* Make a NUL-terminated copy for cJSON. */ + g_autofree char *body_str = g_strndup(data ? data : "", size); + result = parse_response(body_str); + } + } + + if (resp_bytes) { + g_bytes_unref(resp_bytes); + } + g_object_unref(msg); + g_object_unref(session); + + return result; +} + +/* ── Public: agent_llm_list_models ──────────────────────────────────── * + * + * Fetch the list of available models from an OpenAI-compatible API. + * Calls GET {base_url}/models with an Authorization: Bearer header + * (if api_key is non-NULL and non-empty). Parses the "data" array and + * returns a cJSON array of model ID strings. Returns NULL on error. + * Caller must cJSON_Delete() the returned array. + */ +cJSON *agent_llm_list_models(const char *base_url, const char *api_key) { + if (base_url == NULL || base_url[0] == '\0') { + return NULL; + } + + /* Build the full URL: {normalized_base_url}/models. + * normalize_base_url() strips trailing slashes and appends "/v1" + * if needed, so we never produce a "//" here. */ + g_autofree char *norm = normalize_base_url(base_url); + g_autofree char *url = g_strdup_printf("%s/models", norm); + + SoupMessage *msg = soup_message_new("GET", url); + if (msg == NULL) { + g_printerr("[agent_llm] invalid URL: %s\n", url); + return NULL; + } + + if (api_key != NULL && api_key[0] != '\0') { + g_autofree char *bearer = g_strdup_printf("Bearer %s", api_key); + soup_message_headers_append( + soup_message_get_request_headers(msg), "Authorization", bearer); + } + + SoupSession *session = soup_session_new(); + GError *error = NULL; + GBytes *resp_bytes = soup_session_send_and_read(session, msg, NULL, &error); + + cJSON *models = NULL; + + if (error != NULL) { + g_printerr("[agent_llm] list_models transport error: %s\n", + error->message); + g_error_free(error); + } else { + guint status = soup_message_get_status(msg); + if (status != SOUP_STATUS_OK) { + gsize size = 0; + const gchar *data = g_bytes_get_data(resp_bytes, &size); + g_printerr("[agent_llm] list_models HTTP %u: %.*s\n", + status, (int)size, data ? data : ""); + } else { + gsize size = 0; + const gchar *data = g_bytes_get_data(resp_bytes, &size); + g_autofree char *body_str = g_strndup(data ? data : "", size); + + cJSON *root = cJSON_Parse(body_str); + if (root == NULL) { + g_printerr("[agent_llm] list_models: failed to parse JSON\n"); + } else { + cJSON *err = cJSON_GetObjectItemCaseSensitive(root, "error"); + if (err) { + g_printerr("[agent_llm] list_models: API returned error\n"); + } else { + cJSON *data_arr = cJSON_GetObjectItemCaseSensitive(root, "data"); + if (cJSON_IsArray(data_arr)) { + models = cJSON_CreateArray(); + if (models != NULL) { + cJSON *entry; + cJSON_ArrayForEach(entry, data_arr) { + cJSON *id = cJSON_GetObjectItemCaseSensitive(entry, "id"); + if (cJSON_IsString(id) && id->valuestring != NULL) { + cJSON_AddItemToArray(models, + cJSON_CreateString(id->valuestring)); + } + } + /* If no IDs were found, treat as error. */ + if (cJSON_GetArraySize(models) == 0) { + cJSON_Delete(models); + models = NULL; + } + } + } + } + cJSON_Delete(root); + } + } + } + + if (resp_bytes) { + g_bytes_unref(resp_bytes); + } + g_object_unref(msg); + g_object_unref(session); + + return models; +} diff --git a/src/agent_llm.h b/src/agent_llm.h new file mode 100644 index 0000000..aed7777 --- /dev/null +++ b/src/agent_llm.h @@ -0,0 +1,86 @@ +/* + * agent_llm.h — OpenAI-compatible LLM HTTP client + * + * Sends chat-completions requests to an OpenAI-compatible endpoint + * (OpenAI, OpenRouter, Ollama, LM Studio, Groq, etc.) using libsoup-3.0, + * parses the JSON response, and returns the assistant's message (text + * content + any tool_calls). + * + * The HTTP call is synchronous (soup_session_send_and_read) and is + * intended to be called from a background thread — not the GTK main + * thread. A fresh SoupSession is created per call so there are no + * cross-thread sharing issues. + */ + +#ifndef AGENT_LLM_H +#define AGENT_LLM_H + +#include "cjson/cJSON.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ── Response ──────────────────────────────────────────────────────── */ + +typedef struct { + char *content; /* assistant text (may be NULL or empty) */ + cJSON *tool_calls; /* JSON array of tool call objects, or NULL */ + char *finish_reason; /* "stop", "tool_calls", "length", etc. */ +} agent_llm_response_t; + +/* + * Call an OpenAI-compatible chat completions endpoint. + * + * base_url — e.g. "https://api.openai.com/v1" or "http://localhost:11434/v1" + * api_key — bearer token (may be NULL for local servers like Ollama) + * model — model name, e.g. "gpt-4o", "llama3.1", etc. + * messages — cJSON array of message objects (role/content/tool_calls/tool_call_id) + * tools — cJSON array of tool definitions (OpenAI format), or NULL + * + * Returns a newly-allocated agent_llm_response_t. Caller must free with + * agent_llm_response_free(). + * + * On error, returns NULL (caller should handle gracefully). + */ +agent_llm_response_t *agent_llm_chat(const char *base_url, + const char *api_key, + const char *model, + cJSON *messages, + cJSON *tools); + +/* + * Free an agent_llm_response_t returned by agent_llm_chat(). + * Safe to call with NULL. + */ +void agent_llm_response_free(agent_llm_response_t *resp); + +/* + * Build the OpenAI-format "tools" array from the shared tool catalog + * (agent_tool_catalog.h). Each entry is wrapped as: + * + * {"type":"function","function":{"name":...,"description":...,"parameters":{...}}} + * + * Returns a newly-allocated cJSON array. Caller frees with cJSON_Delete(). + * Returns NULL if the catalog is empty or on allocation failure. + */ +cJSON *agent_llm_build_openai_tools(void); + +/* + * Fetch the list of available models from an OpenAI-compatible API. + * Calls GET {base_url}/models with the Authorization header. + * + * base_url — e.g. "https://api.ppq.ai" + * api_key — bearer token (may be NULL for local servers) + * + * Returns a cJSON array of model ID strings (newly allocated), e.g.: + * ["gpt-4o", "gpt-4o-mini", "llama3.1"] + * Returns NULL on error. Caller must cJSON_Delete(). + */ +cJSON *agent_llm_list_models(const char *base_url, const char *api_key); + +#ifdef __cplusplus +} +#endif + +#endif /* AGENT_LLM_H */ diff --git a/src/agent_loop.c b/src/agent_loop.c new file mode 100644 index 0000000..e8e7afc --- /dev/null +++ b/src/agent_loop.c @@ -0,0 +1,484 @@ +/* + * agent_loop.c — ReAct tool-call loop on a background GThread + * + * Implements the standard ReAct loop: + * 1. Load chat history from agent_chat_store_get_messages(). + * 2. Prepend a system prompt message (must be first in the array). + * 3. Call the LLM via agent_llm_chat(). + * 4. If the response contains tool_calls, dispatch each: + * - Browser tools → hop to the GTK main thread via g_idle_add() + + * GAsyncQueue (WebKitGTK is not thread-safe). + * - Filesystem/shell tools → run directly on this thread via + * agent_fs_tools_dispatch(). + * 5. Append tool results to the chat store. + * 6. Repeat until no more tool_calls, the iteration cap is reached, or + * the cancel flag is set. + * 7. Persist the final assistant message and update session status. + * + * See plans/embedded-agent.md for the full concurrency model. + */ + +#include "agent_loop.h" +#include "agent_llm.h" +#include "agent_chat_store.h" +#include "agent_fs_tools.h" +#include "agent_tools.h" +#include "agent_skills.h" +#include "agent_server.h" +#include "settings.h" +#include "cjson/cJSON.h" + +#include +#include + +/* ── Context ────────────────────────────────────────────────────────── */ + +typedef struct { + /* Atomic flags */ + volatile gint cancel_flag; + volatile gint running; /* gboolean as gint */ + + /* Status (protected by status_lock) */ + GMutex status_lock; + agent_loop_state_t state; + int iteration; + char *current_tool; /* tool name being executed */ + char *last_message; /* last assistant text */ + char *error; /* error message */ + + /* Thread handle */ + GThread *thread; +} agent_loop_ctx_t; + +static agent_loop_ctx_t g_ctx = {0}; + +/* ── Status helpers ─────────────────────────────────────────────────── */ + +/* Map a state enum to its string name for JSON events. */ +static const char * +state_name(agent_loop_state_t s) +{ + switch (s) { + case AGENT_LOOP_IDLE: return "idle"; + case AGENT_LOOP_THINKING: return "thinking"; + case AGENT_LOOP_TOOL_CALL: return "tool_call"; + case AGENT_LOOP_COMPLETE: return "complete"; + case AGENT_LOOP_ERROR: return "error"; + case AGENT_LOOP_CANCELLED: return "cancelled"; + } + return "idle"; +} + +/* Emit the current status as a WebSocket push event so the chat UI + * can update instantly without polling. Must be called WITHOUT the + * mutex held (it takes it itself). */ +static void +emit_status_event(void) +{ + g_mutex_lock(&g_ctx.status_lock); + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "state", state_name(g_ctx.state)); + cJSON_AddNumberToObject(data, "iteration", g_ctx.iteration); + if (g_ctx.current_tool) + cJSON_AddStringToObject(data, "current_tool", g_ctx.current_tool); + else + cJSON_AddNullToObject(data, "current_tool"); + if (g_ctx.last_message) + cJSON_AddStringToObject(data, "last_message", g_ctx.last_message); + else + cJSON_AddNullToObject(data, "last_message"); + if (g_ctx.error) + cJSON_AddStringToObject(data, "error", g_ctx.error); + else + cJSON_AddNullToObject(data, "error"); + g_mutex_unlock(&g_ctx.status_lock); + + /* agent_server_emit_event takes ownership of data. */ + agent_server_emit_event("agent_status", data); +} + +static void +set_status(agent_loop_state_t state, int iter, + const char *tool, const char *msg, const char *err) +{ + g_mutex_lock(&g_ctx.status_lock); + g_ctx.state = state; + g_ctx.iteration = iter; + if (tool) { + g_free(g_ctx.current_tool); + g_ctx.current_tool = g_strdup(tool); + } else { + /* Clear tool name when not in a tool-call state. */ + g_free(g_ctx.current_tool); + g_ctx.current_tool = NULL; + } + if (msg) { + g_free(g_ctx.last_message); + g_ctx.last_message = g_strdup(msg); + } + /* Only set the error field when err is non-NULL. When err is NULL + * and we're transitioning to a non-error state, clear any stale + * error so the UI doesn't show an old error message. */ + if (err) { + g_free(g_ctx.error); + g_ctx.error = g_strdup(err); + } else if (state != AGENT_LOOP_ERROR) { + g_free(g_ctx.error); + g_ctx.error = NULL; + } + g_mutex_unlock(&g_ctx.status_lock); + emit_status_event(); +} + +static void +set_state(agent_loop_state_t state) +{ + g_mutex_lock(&g_ctx.status_lock); + g_ctx.state = state; + /* Clear stale error when transitioning to a non-error state. */ + if (state != AGENT_LOOP_ERROR) { + g_free(g_ctx.error); + g_ctx.error = NULL; + } + /* Clear tool name when leaving the tool_call state. */ + if (state != AGENT_LOOP_TOOL_CALL) { + g_free(g_ctx.current_tool); + g_ctx.current_tool = NULL; + } + g_mutex_unlock(&g_ctx.status_lock); + emit_status_event(); +} + +static void +set_error(const char *msg) +{ + g_mutex_lock(&g_ctx.status_lock); + g_ctx.state = AGENT_LOOP_ERROR; + g_free(g_ctx.error); + g_ctx.error = g_strdup(msg); + g_mutex_unlock(&g_ctx.status_lock); + /* Emit a status event so the UI learns about the error immediately. + * Without this, the chat page stays stuck on "Thinking..." because + * the WebSocket push never fires and the polling fallback may have + * been stopped when the WebSocket connected. */ + emit_status_event(); +} + +/* ── Build messages array with system prompt first ──────────────────── */ + +/* + * Build the messages array for the LLM: a system message first, then + * all messages from the chat store. Returns a newly-allocated cJSON + * array (caller frees with cJSON_Delete), or NULL on error. + */ +static cJSON * +build_messages_with_system(const char *system_prompt) +{ + cJSON *history = agent_chat_store_get_messages(); + if (history == NULL) + return NULL; + + cJSON *messages = cJSON_CreateArray(); + if (messages == NULL) { + cJSON_Delete(history); + return NULL; + } + + /* System prompt must be the first message */ + cJSON *sys_msg = cJSON_CreateObject(); + cJSON_AddStringToObject(sys_msg, "role", "system"); + cJSON_AddStringToObject(sys_msg, "content", system_prompt); + cJSON_AddItemToArray(messages, sys_msg); + + /* Copy all history messages into the new array */ + cJSON *msg; + cJSON_ArrayForEach(msg, history) { + cJSON_AddItemToArray(messages, cJSON_Duplicate(msg, 1)); + } + + cJSON_Delete(history); + return messages; +} + +/* ── Background thread ──────────────────────────────────────────────── */ + +static gpointer +agent_loop_thread(gpointer data) +{ + char *user_message = (char *)data; + g_print("[agent-loop] Thread started, message: %s\n", user_message); + + /* 1. Add user message to chat store */ + agent_chat_store_add_user_message(user_message); + g_free(user_message); + /* Notify UI that a message was added. */ + cJSON *umsg = cJSON_CreateObject(); + cJSON_AddStringToObject(umsg, "role", "user"); + agent_server_emit_event("agent_message", umsg); + + /* 2. Get provider settings (copy — they could change while running) */ + const browser_settings_t *s = settings_get(); + char base_url[512], api_key[512], model[128]; + g_strlcpy(base_url, s->agent_llm_base_url, sizeof(base_url)); + g_strlcpy(api_key, s->agent_llm_api_key, sizeof(api_key)); + g_strlcpy(model, s->agent_llm_model, sizeof(model)); + g_print("[agent-loop] base_url=%s model=%s api_key=%s max_iter=%d\n", + base_url, model, api_key[0] ? "(set)" : "(empty)", s->agent_max_iterations); + + /* Build the system prompt. agent_skills_build_system_prompt() now + * always returns a non-NULL string: it starts with the Sovereign + * Browser Skill template (from settings) as the base, then + * appends any selected Nostr skills' templates. */ + char *system_prompt = agent_skills_build_system_prompt(); + if (system_prompt == NULL) { + /* Defensive fallback — should never happen. */ + system_prompt = g_strdup(SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT); + } + g_print("[agent-loop] Using system prompt (%zu bytes)\n", + strlen(system_prompt)); + + int max_iter = s->agent_max_iterations; + if (max_iter <= 0) + max_iter = SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT; + + /* Empty API key → pass NULL (local servers like Ollama don't need one) */ + const char *key_arg = (api_key[0] != '\0') ? api_key : NULL; + + /* 3. Build OpenAI tools array (built once, reused each iteration) */ + cJSON *tools = agent_llm_build_openai_tools(); + + /* 4. ReAct loop */ + for (int iter = 0; iter < max_iter; iter++) { + /* Check cancel */ + if (g_atomic_int_get(&g_ctx.cancel_flag)) { + set_state(AGENT_LOOP_CANCELLED); + break; + } + + /* Update status: thinking */ + set_status(AGENT_LOOP_THINKING, iter, NULL, NULL, NULL); + + /* Build messages: system prompt first, then chat history */ + cJSON *messages = build_messages_with_system(system_prompt); + if (messages == NULL) { + set_error("Failed to load messages"); + break; + } + + /* Call LLM (blocking HTTP on this thread) */ + g_print("[agent-loop] iter %d: calling LLM...\n", iter); + agent_llm_response_t *resp = agent_llm_chat(base_url, key_arg, + model, messages, tools); + cJSON_Delete(messages); + + if (resp == NULL) { + g_print("[agent-loop] iter %d: LLM call returned NULL\n", iter); + set_error("LLM API call failed"); + break; + } + g_print("[agent-loop] iter %d: LLM responded, finish=%s, content=%s, tool_calls=%d\n", + iter, resp->finish_reason ? resp->finish_reason : "(null)", + resp->content ? resp->content : "(null)", + resp->tool_calls ? cJSON_GetArraySize(resp->tool_calls) : 0); + + /* Persist assistant message */ + char *tool_calls_str = (resp->tool_calls != NULL) + ? cJSON_PrintUnformatted(resp->tool_calls) : NULL; + agent_chat_store_add_assistant_message(resp->content, tool_calls_str); + g_free(tool_calls_str); + /* Notify UI that a message was added. */ + cJSON *amsg = cJSON_CreateObject(); + cJSON_AddStringToObject(amsg, "role", "assistant"); + agent_server_emit_event("agent_message", amsg); + + /* Update last_message status */ + set_status(AGENT_LOOP_THINKING, iter, NULL, + resp->content ? resp->content : "", NULL); + + /* If no tool_calls, we're done */ + if (resp->tool_calls == NULL || + cJSON_GetArraySize(resp->tool_calls) == 0) { + agent_llm_response_free(resp); + set_state(AGENT_LOOP_COMPLETE); + goto done; + } + + /* Dispatch each tool call */ + cJSON *tc; + cJSON_ArrayForEach(tc, resp->tool_calls) { + if (g_atomic_int_get(&g_ctx.cancel_flag)) + break; + + cJSON *fn = cJSON_GetObjectItem(tc, "function"); + const char *tool_name = fn + ? cJSON_GetStringValue(cJSON_GetObjectItem(fn, "name")) + : NULL; + const char *args_str = fn + ? cJSON_GetStringValue(cJSON_GetObjectItem(fn, "arguments")) + : NULL; + const char *tc_id = cJSON_GetStringValue(cJSON_GetObjectItem(tc, "id")); + + cJSON *args = (args_str != NULL) + ? cJSON_Parse(args_str) : cJSON_CreateObject(); + if (args == NULL) + args = cJSON_CreateObject(); + + set_status(AGENT_LOOP_TOOL_CALL, iter, + tool_name ? tool_name : "?", NULL, NULL); + g_print("[agent-loop] iter %d: tool %s (args: %s)\n", + iter, tool_name ? tool_name : "?", + args_str ? args_str : "(null)"); + + /* Dispatch: fs/shell tools and browser tools both run on + * this background thread. The browser tools use the same + * sync JS eval path (conn=NULL) as the MCP HTTP handler, + * which calls gtk_main_iteration() to pump the main loop + * while waiting for the async JS result. This is safe to + * call from a background thread — the main loop keeps + * running and processes the JS eval callback. */ + cJSON *result; + if (tool_name != NULL && agent_fs_is_tool(tool_name)) { + result = agent_fs_tools_dispatch(tool_name, args); + } else { + /* Build the request JSON and dispatch directly (same + * as the MCP HTTP handler does from the libsoup thread). */ + cJSON *request = cJSON_CreateObject(); + cJSON_AddStringToObject(request, "tool", + tool_name ? tool_name : ""); + cJSON_AddItemToObject(request, "params", args); + args = NULL; /* transferred to request */ + result = agent_tools_dispatch(request, NULL); + cJSON_Delete(request); + } + if (args) cJSON_Delete(args); + + /* Get result JSON string */ + char *result_str = (result != NULL) + ? cJSON_PrintUnformatted(result) : g_strdup("{}"); + cJSON_Delete(result); + + /* Persist tool result */ + agent_chat_store_add_tool_result( + tc_id ? tc_id : "", result_str ? result_str : "{}"); + g_free(result_str); + /* Notify UI that a tool result was added. */ + cJSON *tmsg = cJSON_CreateObject(); + cJSON_AddStringToObject(tmsg, "role", "tool"); + agent_server_emit_event("agent_message", tmsg); + } + + agent_llm_response_free(resp); + } + + /* If we exited the loop without completing or erroring, we hit the + * iteration cap — treat as complete (the LLM was still working). */ + { + g_mutex_lock(&g_ctx.status_lock); + if (g_ctx.state != AGENT_LOOP_CANCELLED && + g_ctx.state != AGENT_LOOP_ERROR) { + g_ctx.state = AGENT_LOOP_COMPLETE; + } + g_mutex_unlock(&g_ctx.status_lock); + } + +done: + g_free(system_prompt); + cJSON_Delete(tools); + g_atomic_int_set(&g_ctx.running, 0); + return NULL; +} + +/* ── Public API ─────────────────────────────────────────────────────── */ + +/* + * Lazily initialize the mutex on first use. g_mutex_init() is idempotent + * enough for our purposes — we guard with a g_once_init_enter block so + * it only happens once. + */ +static void +ensure_mutex_init(void) +{ + static gsize initialized = 0; + if (g_once_init_enter(&initialized)) { + g_mutex_init(&g_ctx.status_lock); + g_ctx.state = AGENT_LOOP_IDLE; + g_once_init_leave(&initialized, 1); + } +} + +int +agent_loop_run(const char *user_message) +{ + g_print("[agent-loop] agent_loop_run called: %s\n", + user_message ? user_message : "(null)"); + if (user_message == NULL) + return -1; + + ensure_mutex_init(); + + if (g_atomic_int_get(&g_ctx.running)) { + g_print("[agent-loop] already running, rejecting\n"); + return -1; /* already running */ + } + + /* Check that a provider is configured (base URL + model). + * An empty API key is allowed (local servers like Ollama). */ + const browser_settings_t *s = settings_get(); + if (s->agent_llm_base_url[0] == '\0' || s->agent_llm_model[0] == '\0') { + g_print("[agent-loop] no provider configured: base_url=%s model=%s\n", + s->agent_llm_base_url, s->agent_llm_model); + set_error("No LLM provider configured (set base URL and model on " + "sovereign://agents)"); + return -1; + } + + /* Reset cancel flag */ + g_atomic_int_set(&g_ctx.cancel_flag, 0); + + /* Reset status */ + set_status(AGENT_LOOP_THINKING, 0, NULL, NULL, NULL); + + g_atomic_int_set(&g_ctx.running, 1); + + /* Spawn background thread (takes ownership of the duplicated string) */ + g_ctx.thread = g_thread_new("agent-loop", agent_loop_thread, + g_strdup(user_message)); + return 0; +} + +void +agent_loop_cancel(void) +{ + g_atomic_int_set(&g_ctx.cancel_flag, 1); +} + +gboolean +agent_loop_is_running(void) +{ + return g_atomic_int_get(&g_ctx.running) != 0; +} + +void +agent_loop_get_status(agent_loop_state_t *state_out, + int *iteration_out, + char **current_tool_out, + char **last_message_out, + char **error_out) +{ + ensure_mutex_init(); + + g_mutex_lock(&g_ctx.status_lock); + + if (state_out) + *state_out = g_ctx.state; + if (iteration_out) + *iteration_out = g_ctx.iteration; + if (current_tool_out) + *current_tool_out = g_strdup(g_ctx.current_tool); + if (last_message_out) + *last_message_out = g_strdup(g_ctx.last_message); + if (error_out) + *error_out = g_strdup(g_ctx.error); + + g_mutex_unlock(&g_ctx.status_lock); +} diff --git a/src/agent_loop.h b/src/agent_loop.h new file mode 100644 index 0000000..8491e14 --- /dev/null +++ b/src/agent_loop.h @@ -0,0 +1,81 @@ +/* + * agent_loop.h — ReAct tool-call loop for the embedded agent + * + * Runs the standard ReAct (reason → act → observe) loop on a background + * GThread so the GTK main thread stays responsive during long LLM calls + * and multi-step tool sequences. + * + * Threading model (see plans/embedded-agent.md): + * - LLM HTTP calls block on the background thread. + * - Browser tools (snapshot, click, eval, …) hop to the GTK main thread + * via g_idle_add() + GAsyncQueue because WebKitGTK is not thread-safe. + * - Filesystem/shell tools run directly on the background thread. + * - SQLite writes use the FULLMUTEX connection and are safe from any thread. + * - Cancellation is via an atomic flag checked at the top of each iteration. + */ + +#ifndef AGENT_LOOP_H +#define AGENT_LOOP_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Status states for the agent loop. + */ +typedef enum { + AGENT_LOOP_IDLE, + AGENT_LOOP_THINKING, + AGENT_LOOP_TOOL_CALL, + AGENT_LOOP_COMPLETE, + AGENT_LOOP_ERROR, + AGENT_LOOP_CANCELLED +} agent_loop_state_t; + +/* + * Start the agent loop for a user message. This: + * 1. Adds the user message to the chat store. + * 2. Spawns a background GThread that runs the ReAct loop. + * 3. Returns immediately (non-blocking). + * + * Returns 0 on success, -1 on error (e.g. already running, no provider + * configured). + */ +int agent_loop_run(const char *user_message); + +/* + * Request cancellation of the running agent loop. + * Sets an atomic flag checked at the top of each loop iteration. + * The background thread will exit at the next check point. + */ +void agent_loop_cancel(void); + +/* + * Check if the agent loop is currently running. + */ +gboolean agent_loop_is_running(void); + +/* + * Get the current status of the agent loop. Returns the state and + * fills the optional output parameters with current status info. + * + * state_out — current state (may be NULL) + * iteration_out — current iteration number (may be NULL) + * current_tool_out — name of tool being executed (may be NULL, caller g_free) + * last_message_out — most recent assistant text (may be NULL, caller g_free) + * error_out — error message if state is ERROR (may be NULL, caller g_free) + */ +void agent_loop_get_status(agent_loop_state_t *state_out, + int *iteration_out, + char **current_tool_out, + char **last_message_out, + char **error_out); + +#ifdef __cplusplus +} +#endif + +#endif /* AGENT_LOOP_H */ diff --git a/src/agent_mcp.c b/src/agent_mcp.c index d320441..0068e4a 100644 --- a/src/agent_mcp.c +++ b/src/agent_mcp.c @@ -15,6 +15,7 @@ #include "agent_tools.h" #include "agent_snapshot.h" #include "agent_login.h" +#include "agent_tool_catalog.h" #include "tab_manager.h" #include "version.h" @@ -117,17 +118,13 @@ static cJSON *rpc_error(int id, int code, const char *message) { } /* ── Tool catalog ─────────────────────────────────────────────────── * - * Static array of tool definitions. Each has a name, description, - * and JSON Schema for input parameters. + * Array of tool definitions. Each has a name, description, and JSON + * Schema for input parameters. The mcp_tool_def_t struct, the array, + * and build_tools_list() are declared in agent_tool_catalog.h so they + * can be shared with the embedded agent (agent_llm.c). */ -typedef struct { - const char *name; - const char *description; - const char *schema_json; /* pre-built JSON schema string */ -} mcp_tool_def_t; - -static mcp_tool_def_t tool_defs[] = { +const mcp_tool_def_t tool_defs[] = { /* Login tools */ {"login_status", "Check if the browser is logged in. Returns the current login state, method, and pubkey if logged in.", @@ -548,13 +545,38 @@ static mcp_tool_def_t tool_defs[] = { {"screenshot_annotated", "Take a screenshot with element ref labels overlaid on the page. Combines snapshot and screenshot — returns both an image and the text accessibility tree.", "{\"type\":\"object\",\"properties\":{\"interactive\":{\"type\":\"boolean\",\"default\":true},\"compact\":{\"type\":\"boolean\",\"default\":true}}}"}, + + /* Filesystem & shell tools (work before login — system-level) */ + {"fs_read", + "Read a file's text contents from the local filesystem. The browser runs in a dedicated qube, so full access is intended. Returns the file content as a string.", + "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Absolute or relative path to the file\"}},\"required\":[\"path\"]}"}, + + {"fs_write", + "Write text to a file on the local filesystem. Overwrites if the file exists, creates it (and parent directories) if it doesn't. Returns the number of bytes written.", + "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file\"},\"content\":{\"type\":\"string\",\"description\":\"Text content to write\"}},\"required\":[\"path\",\"content\"]}"}, + + {"fs_list", + "List directory entries (files and subdirectories). Returns each entry's name, type (file/dir/link/other), and size in bytes.", + "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the directory\"}},\"required\":[\"path\"]}"}, + + {"fs_mkdir", + "Create a directory, including parent directories if needed (recursive, like mkdir -p).", + "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the directory to create\"}},\"required\":[\"path\"]}"}, + + {"fs_delete", + "Delete a file or an empty directory. Non-empty directories must be removed with shell_exec (e.g. rm -rf).", + "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file or empty directory\"}},\"required\":[\"path\"]}"}, + + {"shell_exec", + "Run a shell command via /bin/sh -c and return stdout, stderr, and the exit code. The browser runs in a dedicated qube, so full shell access is intended. A timeout (default 30000ms) kills the command if it runs too long.", + "{\"type\":\"object\",\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout_ms\":{\"type\":\"integer\",\"default\":30000,\"description\":\"Timeout in milliseconds\"}},\"required\":[\"command\"]}"}, }; -static int tool_defs_count = sizeof(tool_defs) / sizeof(tool_defs[0]); +const int tool_defs_count = (int)(sizeof(tool_defs) / sizeof(tool_defs[0])); /* ── Build tools/list response ────────────────────────────────────── */ -static cJSON *build_tools_list(void) { +cJSON *build_tools_list(void) { cJSON *tools = cJSON_CreateArray(); for (int i = 0; i < tool_defs_count; i++) { cJSON *tool = cJSON_CreateObject(); diff --git a/src/agent_skills.c b/src/agent_skills.c new file mode 100644 index 0000000..78bcb49 --- /dev/null +++ b/src/agent_skills.c @@ -0,0 +1,833 @@ +/* + * agent_skills.c — Nostr kind 31123 skill management for sovereign_browser + * + * Implements fetch, selection, system-prompt building, publish, and delete + * for kind 31123 skill events. Skills are PUBLIC events (no NIP-44 + * encryption) authored by any user. sovereign_browser fetches them from + * the local SQLite cache (populated by the relay fetch thread on startup) + * and lets users select skills whose templates are concatenated into the + * system prompt. + * + * Reuses the event signing + relay publishing patterns from + * settings_sync.c, bookmarks.c, and agent_conversations.c. + * + * See plans/cross-project-agent-sync.md (Phase C/D) for the full design. + */ + +#include "agent_skills.h" +#include "db.h" +#include "settings.h" + +#include +#include +#include +#include + +#include "nostr_core/nostr_core.h" +#include "../nostr_core_lib/cjson/cJSON.h" + +/* ── Global state ───────────────────────────────────────────────────── */ + +static nostr_signer_t *g_signer = NULL; +static char g_pubkey[65] = {0}; +static int g_have_signer = 0; + +/* ── Init ───────────────────────────────────────────────────────────── */ + +void agent_skills_init(nostr_signer_t *signer, const char *pubkey_hex) { + g_signer = signer; + g_have_signer = (signer != NULL); + if (pubkey_hex) { + snprintf(g_pubkey, sizeof(g_pubkey), "%s", pubkey_hex); + } else { + g_pubkey[0] = '\0'; + } +} + +void agent_skills_set_signer(nostr_signer_t *signer, const char *pubkey_hex) { + agent_skills_init(signer, pubkey_hex); +} + +/* ── Helpers ────────────────────────────────────────────────────────── */ + +/* Parse bootstrap relays from settings into an array. + * Returns the count. Fills urls_out (pointers into relay_buf). + * Caller must g_free(relay_buf) after use. (Same pattern as + * settings_sync.c, bookmarks.c, agent_conversations.c.) */ +static int parse_relays(char **relay_buf, const char **urls_out, int max) { + const browser_settings_t *s = settings_get(); + *relay_buf = g_strdup(s->bootstrap_relays); + int count = 0; + char *saveptr = NULL; + char *line = strtok_r(*relay_buf, "\n\r", &saveptr); + while (line != NULL && count < max) { + while (*line == ' ' || *line == '\t') line++; + if (line[0] != '\0' && + (strncmp(line, "wss://", 6) == 0 || + strncmp(line, "ws://", 5) == 0)) { + urls_out[count++] = line; + } + line = strtok_r(NULL, "\n\r", &saveptr); + } + return count; +} + +/* Get the value of the first tag with the given name. Returns a pointer + * into the event's tags (or NULL). Not owned by caller. */ +static const char *event_tag_value(const cJSON *event, const char *name) { + cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags"); + if (!cJSON_IsArray(tags)) return NULL; + cJSON *tag; + cJSON_ArrayForEach(tag, tags) { + if (!cJSON_IsArray(tag)) continue; + cJSON *t0 = cJSON_GetArrayItem(tag, 0); + cJSON *t1 = cJSON_GetArrayItem(tag, 1); + if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 && + cJSON_IsString(t1)) { + return t1->valuestring; + } + } + return NULL; +} + +/* Check whether an event has a tag [name, value]. Returns 1/0. */ +static int event_has_tag(const cJSON *event, const char *name, + const char *value) { + cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags"); + if (!cJSON_IsArray(tags)) return 0; + cJSON *tag; + cJSON_ArrayForEach(tag, tags) { + if (!cJSON_IsArray(tag)) continue; + cJSON *t0 = cJSON_GetArrayItem(tag, 0); + cJSON *t1 = cJSON_GetArrayItem(tag, 1); + if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 && + cJSON_IsString(t1) && strcmp(t1->valuestring, value) == 0) { + return 1; + } + } + return 0; +} + +/* Collect all values of tags with the given name into a newly allocated + * cJSON array of strings. Caller must cJSON_Delete(). */ +static cJSON *event_tag_values(const cJSON *event, const char *name) { + cJSON *result = cJSON_CreateArray(); + cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags"); + if (!cJSON_IsArray(tags)) return result; + cJSON *tag; + cJSON_ArrayForEach(tag, tags) { + if (!cJSON_IsArray(tag)) continue; + cJSON *t0 = cJSON_GetArrayItem(tag, 0); + cJSON *t1 = cJSON_GetArrayItem(tag, 1); + if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 && + cJSON_IsString(t1)) { + cJSON_AddItemToArray(result, cJSON_CreateString(t1->valuestring)); + } + } + return result; +} + +/* ── Fetch / list ──────────────────────────────────────────────────── */ + +/* Parse a single kind 31123 event into an agent_skill_t struct. + * Returns 0 on success, -1 on error. On success, the skill's owned + * fields (content, requires_tools) are allocated and must be freed + * via agent_skills_free_list(). */ +static int parse_skill_event(const cJSON *event, agent_skill_t *out) { + if (event == NULL || out == NULL) return -1; + memset(out, 0, sizeof(*out)); + + /* d-tag (unique identifier) */ + const char *d = event_tag_value(event, "d"); + if (d == NULL || d[0] == '\0') return -1; + snprintf(out->d_tag, sizeof(out->d_tag), "%s", d); + + /* name tag */ + const char *name = event_tag_value(event, "name"); + if (name) { + snprintf(out->name, sizeof(out->name), "%s", name); + } else { + /* Fall back to the d-tag if no name tag. */ + snprintf(out->name, sizeof(out->name), "%s", d); + } + + /* description tag */ + const char *desc = event_tag_value(event, "description"); + if (desc) { + snprintf(out->description, sizeof(out->description), "%s", desc); + } + + /* author pubkey */ + cJSON *pk = cJSON_GetObjectItemCaseSensitive(event, "pubkey"); + if (cJSON_IsString(pk)) { + snprintf(out->pubkey, sizeof(out->pubkey), "%s", pk->valuestring); + } + + /* content (system prompt template) */ + cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content"); + if (cJSON_IsString(content)) { + out->content = g_strdup(content->valuestring); + } else { + out->content = g_strdup(""); + } + + /* requires_tool tags */ + cJSON *tools = event_tag_values(event, "requires_tool"); + if (tools != NULL) { + int n = cJSON_GetArraySize(tools); + if (n > AGENT_SKILL_MAX_TOOLS) n = AGENT_SKILL_MAX_TOOLS; + out->tool_count = n; + if (n > 0) { + out->requires_tools = g_new0(char *, n); + for (int i = 0; i < n; i++) { + cJSON *t = cJSON_GetArrayItem(tools, i); + if (cJSON_IsString(t)) { + out->requires_tools[i] = g_strdup(t->valuestring); + } else { + out->requires_tools[i] = g_strdup(""); + } + } + } + cJSON_Delete(tools); + } + + return 0; +} + +void agent_skills_free_list(agent_skill_t *skills, int count) { + if (skills == NULL) return; + for (int i = 0; i < count; i++) { + g_free(skills[i].content); + if (skills[i].requires_tools) { + for (int j = 0; j < skills[i].tool_count; j++) { + g_free(skills[i].requires_tools[j]); + } + g_free(skills[i].requires_tools); + } + } + g_free(skills); +} + +/* Fetch and parse all kind 31123 events into an array of agent_skill_t. + * Returns the array (caller must agent_skills_free_list()) and sets + * *count_out. Returns NULL on error or if no skills exist. */ +static agent_skill_t *fetch_skill_structs(int *count_out) { + if (count_out == NULL) return NULL; + *count_out = 0; + + cJSON *events = db_get_events_by_kind(AGENT_SKILL_KIND, 500); + if (events == NULL) return NULL; + + int n = cJSON_GetArraySize(events); + if (n <= 0) { + cJSON_Delete(events); + return NULL; + } + + /* Deduplicate by d-tag: keep the newest (events are newest-first + * from db_get_events_by_kind). */ + agent_skill_t *skills = g_new0(agent_skill_t, n); + int count = 0; + cJSON *seen_d = cJSON_CreateArray(); + + for (int i = 0; i < n; i++) { + cJSON *ev = cJSON_GetArrayItem(events, i); + const char *d = event_tag_value(ev, "d"); + if (d == NULL || d[0] == '\0') continue; + + /* Skip if we've already seen this d-tag. */ + int seen = 0; + cJSON *s; + cJSON_ArrayForEach(s, seen_d) { + if (cJSON_IsString(s) && strcmp(s->valuestring, d) == 0) { + seen = 1; + break; + } + } + if (seen) continue; + cJSON_AddItemToArray(seen_d, cJSON_CreateString(d)); + + if (parse_skill_event(ev, &skills[count]) == 0) { + count++; + } + } + + cJSON_Delete(seen_d); + cJSON_Delete(events); + + if (count == 0) { + g_free(skills); + return NULL; + } + + *count_out = count; + return skills; +} + +cJSON *agent_skills_fetch(void) { + cJSON *result = cJSON_CreateArray(); + + int count = 0; + agent_skill_t *skills = fetch_skill_structs(&count); + if (skills == NULL) return result; + + for (int i = 0; i < count; i++) { + cJSON *obj = cJSON_CreateObject(); + cJSON_AddStringToObject(obj, "d", skills[i].d_tag); + cJSON_AddStringToObject(obj, "name", skills[i].name); + cJSON_AddStringToObject(obj, "description", skills[i].description); + cJSON_AddStringToObject(obj, "pubkey", skills[i].pubkey); + cJSON_AddStringToObject(obj, "content", skills[i].content); + + /* requires_tools array */ + cJSON *tools = cJSON_CreateArray(); + for (int j = 0; j < skills[i].tool_count; j++) { + cJSON_AddItemToArray(tools, + cJSON_CreateString(skills[i].requires_tools[j])); + } + cJSON_AddItemToObject(obj, "requires_tools", tools); + + cJSON_AddItemToArray(result, obj); + } + + agent_skills_free_list(skills, count); + return result; +} + +/* ── Sovereign Browser Skill (default, from local settings) ─────────── */ + +cJSON *agent_skills_get_default(void) { + const browser_settings_t *s = settings_get(); + + cJSON *obj = cJSON_CreateObject(); + /* d is empty — this skill is unsaved (not published to Nostr). */ + cJSON_AddStringToObject(obj, "d", ""); + cJSON_AddStringToObject(obj, "name", + s->agent_skill_name[0] ? s->agent_skill_name + : SETTINGS_AGENT_SKILL_NAME_DEFAULT); + cJSON_AddStringToObject(obj, "description", + s->agent_skill_description[0] ? s->agent_skill_description + : SETTINGS_AGENT_SKILL_DESCRIPTION_DEFAULT); + cJSON_AddStringToObject(obj, "content", + s->agent_skill_template[0] ? s->agent_skill_template + : SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT); + cJSON_AddStringToObject(obj, "pubkey", ""); + cJSON_AddBoolToObject(obj, "unsaved", 1); + + /* Parse requires_tools (comma-separated) into a JSON array. */ + cJSON *tools = cJSON_CreateArray(); + const char *tools_str = s->agent_skill_requires_tools[0] + ? s->agent_skill_requires_tools + : SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_DEFAULT; + char *buf = g_strdup(tools_str); + char *saveptr = NULL; + char *tok = strtok_r(buf, ",", &saveptr); + while (tok != NULL) { + /* Trim leading whitespace. */ + while (*tok == ' ' || *tok == '\t') tok++; + if (tok[0] != '\0') { + cJSON_AddItemToArray(tools, cJSON_CreateString(tok)); + } + tok = strtok_r(NULL, ",", &saveptr); + } + g_free(buf); + cJSON_AddItemToObject(obj, "requires_tools", tools); + + return obj; +} + +/* ── Selection (local settings) ────────────────────────────────────── */ + +cJSON *agent_skills_get_selected(void) { + cJSON *result = cJSON_CreateArray(); + char *json_str = db_kv_get_copy(AGENT_SKILLS_SELECTED_KEY); + if (json_str == NULL || json_str[0] == '\0') { + g_free(json_str); + return result; + } + + cJSON *parsed = cJSON_Parse(json_str); + g_free(json_str); + if (cJSON_IsArray(parsed)) { + cJSON *item; + cJSON_ArrayForEach(item, parsed) { + if (cJSON_IsString(item)) { + cJSON_AddItemToArray(result, cJSON_CreateString(item->valuestring)); + } + } + } + if (parsed) cJSON_Delete(parsed); + return result; +} + +int agent_skills_set_selected(const char *d_tags_json) { + if (d_tags_json == NULL) d_tags_json = "[]"; + return db_kv_set(AGENT_SKILLS_SELECTED_KEY, d_tags_json); +} + +cJSON *agent_skills_toggle_selected(const char *d_tag) { + if (d_tag == NULL || d_tag[0] == '\0') return NULL; + + cJSON *selected = agent_skills_get_selected(); + + /* Check if already selected. */ + int found = -1; + int n = cJSON_GetArraySize(selected); + for (int i = 0; i < n; i++) { + cJSON *item = cJSON_GetArrayItem(selected, i); + if (cJSON_IsString(item) && strcmp(item->valuestring, d_tag) == 0) { + found = i; + break; + } + } + + if (found >= 0) { + /* Remove it. */ + cJSON_DeleteItemFromArray(selected, found); + } else { + /* Add it (at the end to preserve selection order). */ + cJSON_AddItemToArray(selected, cJSON_CreateString(d_tag)); + } + + /* Save back to db_kv. */ + char *json = cJSON_PrintUnformatted(selected); + if (json) { + agent_skills_set_selected(json); + free(json); + } + + return selected; +} + +/* ── Build system prompt ───────────────────────────────────────────── */ + +char *agent_skills_build_system_prompt(void) { + /* Always start with the Sovereign Browser Skill template (the + * default system prompt from local settings). This is the base + * prompt; selected Nostr skills are appended after it. */ + const browser_settings_t *s = settings_get(); + const char *base = s->agent_skill_template[0] + ? s->agent_skill_template + : SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT; + + GString *combined = g_string_new(base); + + /* Fetch selected skills and append their templates. */ + cJSON *selected = agent_skills_get_selected(); + if (selected != NULL) { + int n = cJSON_GetArraySize(selected); + if (n > 0) { + int skill_count = 0; + agent_skill_t *skills = fetch_skill_structs(&skill_count); + if (skills != NULL) { + for (int i = 0; i < n; i++) { + cJSON *item = cJSON_GetArrayItem(selected, i); + if (!cJSON_IsString(item)) continue; + const char *d = item->valuestring; + + agent_skill_t *skill = NULL; + for (int j = 0; j < skill_count; j++) { + if (strcmp(skills[j].d_tag, d) == 0) { + skill = &skills[j]; + break; + } + } + if (skill == NULL || skill->content == NULL || + skill->content[0] == '\0') continue; + + g_string_append(combined, "\n\n---\n\n"); + g_string_append(combined, skill->content); + } + agent_skills_free_list(skills, skill_count); + } + } + cJSON_Delete(selected); + } + + return g_string_free(combined, FALSE); +} + +/* ── Publish ───────────────────────────────────────────────────────── */ + +/* Derive a d-tag from a skill name: lowercase, replace spaces/separators + * with hyphens, strip non-alphanumeric. Returns a newly allocated string + * (caller must g_free). */ +static char *derive_d_tag(const char *name) { + if (name == NULL || name[0] == '\0') { + /* Fall back to a timestamp-based id. */ + return g_strdup_printf("skill-%ld", (long)time(NULL)); + } + + /* Build a slug. */ + GString *slug = g_string_new(NULL); + for (const char *p = name; *p; p++) { + char c = *p; + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { + g_string_append_c(slug, c); + } else if (c >= 'A' && c <= 'Z') { + g_string_append_c(slug, c - 'A' + 'a'); + } else if (c == ' ' || c == '_' || c == '-' || c == '.') { + g_string_append_c(slug, '-'); + } + /* Skip other characters. */ + } + + /* Collapse consecutive hyphens and trim leading/trailing. */ + GString *clean = g_string_new(NULL); + int prev_hyphen = 1; /* treat start as if previous was hyphen */ + for (gsize i = 0; i < slug->len; i++) { + char c = slug->str[i]; + if (c == '-') { + if (!prev_hyphen) { + g_string_append_c(clean, '-'); + prev_hyphen = 1; + } + } else { + g_string_append_c(clean, c); + prev_hyphen = 0; + } + } + /* Trim trailing hyphen. */ + if (clean->len > 0 && clean->str[clean->len - 1] == '-') { + g_string_truncate(clean, clean->len - 1); + } + g_string_free(slug, TRUE); + + if (clean->len == 0) { + g_string_free(clean, TRUE); + return g_strdup_printf("skill-%ld", (long)time(NULL)); + } + + /* Append a short timestamp suffix to ensure uniqueness. */ + char *result = g_strdup_printf("%s-%ld", clean->str, (long)time(NULL) % 100000); + g_string_free(clean, TRUE); + return result; +} + +char *agent_skills_publish(const char *name, + const char *description, + const char *content, + const char *requires_tools_json) { + if (!g_have_signer) { + g_printerr("[agent_skills] No signer, cannot publish\n"); + return NULL; + } + if (content == NULL || content[0] == '\0') { + g_printerr("[agent_skills] Content is required\n"); + return NULL; + } + + /* Derive the d-tag from the name. */ + char *d_tag = derive_d_tag(name); + + /* Build tags: + * [["d", d_tag], + * ["name", name], + * ["description", desc], + * ["client", "sovereign_browser"], + * ["requires_tool", tool1], ...] */ + cJSON *tags = cJSON_CreateArray(); + + cJSON *d_tag_arr = cJSON_CreateArray(); + cJSON_AddItemToArray(d_tag_arr, cJSON_CreateString("d")); + cJSON_AddItemToArray(d_tag_arr, cJSON_CreateString(d_tag)); + cJSON_AddItemToArray(tags, d_tag_arr); + + if (name && name[0]) { + cJSON *name_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(name_tag, cJSON_CreateString("name")); + cJSON_AddItemToArray(name_tag, cJSON_CreateString(name)); + cJSON_AddItemToArray(tags, name_tag); + } + + if (description && description[0]) { + cJSON *desc_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(desc_tag, cJSON_CreateString("description")); + cJSON_AddItemToArray(desc_tag, cJSON_CreateString(description)); + cJSON_AddItemToArray(tags, desc_tag); + } + + cJSON *client_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(client_tag, cJSON_CreateString("client")); + cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser")); + cJSON_AddItemToArray(tags, client_tag); + + /* Parse requires_tools_json and add a requires_tool tag for each. */ + if (requires_tools_json && requires_tools_json[0]) { + cJSON *tools = cJSON_Parse(requires_tools_json); + if (cJSON_IsArray(tools)) { + int n = cJSON_GetArraySize(tools); + if (n > AGENT_SKILL_MAX_TOOLS) n = AGENT_SKILL_MAX_TOOLS; + for (int i = 0; i < n; i++) { + cJSON *t = cJSON_GetArrayItem(tools, i); + if (cJSON_IsString(t) && t->valuestring[0]) { + cJSON *rt_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(rt_tag, cJSON_CreateString("requires_tool")); + cJSON_AddItemToArray(rt_tag, cJSON_CreateString(t->valuestring)); + cJSON_AddItemToArray(tags, rt_tag); + } + } + } + if (tools) cJSON_Delete(tools); + } + + /* Create and sign the event. Skills are PUBLIC — no encryption. + * The content is the raw system prompt template. */ + cJSON *event = nostr_create_and_sign_event_with_signer( + AGENT_SKILL_KIND, content, tags, g_signer, time(NULL)); + cJSON_Delete(tags); + + if (event == NULL) { + g_printerr("[agent_skills] Failed to create/sign event\n"); + g_free(d_tag); + return NULL; + } + + /* Store in SQLite. */ + db_store_event(event); + + /* Publish to bootstrap relays. */ + char *relay_buf = NULL; + const char *relay_urls[32]; + int relay_count = parse_relays(&relay_buf, relay_urls, 32); + + if (relay_count > 0) { + int success_count = 0; + publish_result_t *results = synchronous_publish_event_with_progress( + relay_urls, relay_count, event, &success_count, + 15, NULL, NULL, 0, NULL); + if (results) free(results); + g_print("[agent_skills] Published skill '%s' (d:%s) to %d/%d relays\n", + name ? name : "(unnamed)", d_tag, success_count, relay_count); + } else { + g_print("[agent_skills] No relays configured, skill stored locally\n"); + } + + g_free(relay_buf); + cJSON_Delete(event); + return d_tag; +} + +/* ── Delete ────────────────────────────────────────────────────────── */ + +int agent_skills_delete(const char *d_tag) { + if (d_tag == NULL || d_tag[0] == '\0') return -1; + if (g_pubkey[0] == '\0') return -1; + + /* Find the kind 31123 event with this d-tag authored by the user. */ + cJSON *events = db_get_events(g_pubkey, AGENT_SKILL_KIND, 500); + if (events == NULL) return -1; + + char *event_id = NULL; + int n = cJSON_GetArraySize(events); + for (int i = 0; i < n; i++) { + cJSON *ev = cJSON_GetArrayItem(events, i); + if (event_has_tag(ev, "d", d_tag)) { + cJSON *id_item = cJSON_GetObjectItemCaseSensitive(ev, "id"); + if (cJSON_IsString(id_item)) { + event_id = g_strdup(id_item->valuestring); + } + break; + } + } + cJSON_Delete(events); + + if (event_id == NULL) { + g_printerr("[agent_skills] Skill '%s' not found for delete\n", d_tag); + return -1; + } + + /* Publish a kind 5 deletion event referencing the skill event. */ + if (g_have_signer) { + cJSON *tags = cJSON_CreateArray(); + cJSON *e_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(e_tag, cJSON_CreateString("e")); + cJSON_AddItemToArray(e_tag, cJSON_CreateString(event_id)); + cJSON_AddItemToArray(tags, e_tag); + + cJSON *event = nostr_create_and_sign_event_with_signer( + 5, "Deleted skill", tags, g_signer, time(NULL)); + cJSON_Delete(tags); + + if (event) { + db_store_event(event); + + char *relay_buf = NULL; + const char *relay_urls[32]; + int relay_count = parse_relays(&relay_buf, relay_urls, 32); + if (relay_count > 0) { + int success_count = 0; + publish_result_t *results = + synchronous_publish_event_with_progress( + relay_urls, relay_count, event, &success_count, + 15, NULL, NULL, 0, NULL); + if (results) free(results); + g_print("[agent_skills] Published deletion for '%s' to %d/%d relays\n", + d_tag, success_count, relay_count); + } + g_free(relay_buf); + cJSON_Delete(event); + } + } + + /* Remove from local SQLite cache. */ + db_delete_event(event_id); + g_free(event_id); + + /* Also remove from the selected list if present. */ + cJSON *selected = agent_skills_get_selected(); + if (selected) { + int sn = cJSON_GetArraySize(selected); + for (int i = 0; i < sn; i++) { + cJSON *item = cJSON_GetArrayItem(selected, i); + if (cJSON_IsString(item) && strcmp(item->valuestring, d_tag) == 0) { + cJSON_DeleteItemFromArray(selected, i); + break; + } + } + char *json = cJSON_PrintUnformatted(selected); + if (json) { + agent_skills_set_selected(json); + free(json); + } + cJSON_Delete(selected); + } + + return 0; +} + +/* ── Update (re-publish an existing skill) ──────────────────────────── */ + +int agent_skills_update(const char *d_tag, + const char *name, + const char *description, + const char *content, + const char *requires_tools_json) { + if (d_tag == NULL || d_tag[0] == '\0') return -1; + if (content == NULL || content[0] == '\0') return -1; + if (!g_have_signer || g_pubkey[0] == '\0') { + g_printerr("[agent_skills] No signer, cannot update\n"); + return -1; + } + + /* Find the existing kind 31123 event with this d-tag authored by + * the user. We reuse the existing d-tag (addressable event) so the + * update replaces the prior version. */ + cJSON *events = db_get_events(g_pubkey, AGENT_SKILL_KIND, 500); + if (events == NULL) { + g_printerr("[agent_skills] No skill events found for update\n"); + return -1; + } + + cJSON *found = NULL; + int n = cJSON_GetArraySize(events); + for (int i = 0; i < n; i++) { + cJSON *ev = cJSON_GetArrayItem(events, i); + if (event_has_tag(ev, "d", d_tag)) { + found = cJSON_Duplicate(ev, 1); + break; + } + } + cJSON_Delete(events); + + if (found == NULL) { + g_printerr("[agent_skills] Skill '%s' not found for update\n", d_tag); + return -1; + } + + /* If name/description not provided, keep the existing values. */ + char name_buf[128]; + char desc_buf[512]; + if (name == NULL || name[0] == '\0') { + const char *old = event_tag_value(found, "name"); + snprintf(name_buf, sizeof(name_buf), "%s", old ? old : d_tag); + name = name_buf; + } + if (description == NULL) { + const char *old = event_tag_value(found, "description"); + snprintf(desc_buf, sizeof(desc_buf), "%s", old ? old : ""); + description = desc_buf; + } + + /* Build the new tags: same d-tag, new name/description, client tag, + * and requires_tool tags. */ + cJSON *tags = cJSON_CreateArray(); + + cJSON *d_tag_arr = cJSON_CreateArray(); + cJSON_AddItemToArray(d_tag_arr, cJSON_CreateString("d")); + cJSON_AddItemToArray(d_tag_arr, cJSON_CreateString(d_tag)); + cJSON_AddItemToArray(tags, d_tag_arr); + + if (name && name[0]) { + cJSON *name_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(name_tag, cJSON_CreateString("name")); + cJSON_AddItemToArray(name_tag, cJSON_CreateString(name)); + cJSON_AddItemToArray(tags, name_tag); + } + + if (description && description[0]) { + cJSON *desc_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(desc_tag, cJSON_CreateString("description")); + cJSON_AddItemToArray(desc_tag, cJSON_CreateString(description)); + cJSON_AddItemToArray(tags, desc_tag); + } + + cJSON *client_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(client_tag, cJSON_CreateString("client")); + cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser")); + cJSON_AddItemToArray(tags, client_tag); + + if (requires_tools_json && requires_tools_json[0]) { + cJSON *tools = cJSON_Parse(requires_tools_json); + if (cJSON_IsArray(tools)) { + int tn = cJSON_GetArraySize(tools); + if (tn > AGENT_SKILL_MAX_TOOLS) tn = AGENT_SKILL_MAX_TOOLS; + for (int i = 0; i < tn; i++) { + cJSON *t = cJSON_GetArrayItem(tools, i); + if (cJSON_IsString(t) && t->valuestring[0]) { + cJSON *rt_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(rt_tag, cJSON_CreateString("requires_tool")); + cJSON_AddItemToArray(rt_tag, cJSON_CreateString(t->valuestring)); + cJSON_AddItemToArray(tags, rt_tag); + } + } + } + if (tools) cJSON_Delete(tools); + } + + /* Create and sign the new event (same d-tag → replaces the old). */ + cJSON *event = nostr_create_and_sign_event_with_signer( + AGENT_SKILL_KIND, content, tags, g_signer, time(NULL)); + cJSON_Delete(tags); + cJSON_Delete(found); + + if (event == NULL) { + g_printerr("[agent_skills] Failed to create/sign update event\n"); + return -1; + } + + /* Store in SQLite (replaces the old event by id; the d-tag is the + * same so fetch_skill_structs dedup keeps the newest). */ + db_store_event(event); + + /* Publish to bootstrap relays. */ + char *relay_buf = NULL; + const char *relay_urls[32]; + int relay_count = parse_relays(&relay_buf, relay_urls, 32); + + if (relay_count > 0) { + int success_count = 0; + publish_result_t *results = synchronous_publish_event_with_progress( + relay_urls, relay_count, event, &success_count, + 15, NULL, NULL, 0, NULL); + if (results) free(results); + g_print("[agent_skills] Updated skill '%s' to %d/%d relays\n", + d_tag, success_count, relay_count); + } else { + g_print("[agent_skills] No relays configured, update stored locally\n"); + } + + g_free(relay_buf); + cJSON_Delete(event); + return 0; +} diff --git a/src/agent_skills.h b/src/agent_skills.h new file mode 100644 index 0000000..072972a --- /dev/null +++ b/src/agent_skills.h @@ -0,0 +1,204 @@ +/* + * agent_skills.h — Nostr kind 31123 skill management for sovereign_browser + * + * Skills are PUBLIC Nostr events (kind 31123) that define system prompt + * templates, LLM parameters, and tool requirements. They are addressable + * events (NIP-51 list kind variant) with a "d" tag (unique identifier), + * a "name" tag, and content that is the system prompt template. + * + * sovereign_browser has a unique advantage over ~/lt/client/www/ai.html: + * it has browser tools + filesystem/shell tools, so skills with + * "requires_tool" tags are fully functional here. + * + * Users select multiple skills via checkboxes; their templates are + * concatenated into the system prompt in selection order. The agent loop + * calls agent_skills_build_system_prompt() to get the combined prompt. + * + * See plans/cross-project-agent-sync.md (Phase C/D) and + * ~/lt/client/plans/ai-skills-integration.md for the full design. + */ + +#ifndef AGENT_SKILLS_H +#define AGENT_SKILLS_H + +#include + +/* cJSON is in the vendored nostr_core_lib */ +#include "../nostr_core_lib/cjson/cJSON.h" + +/* nostr_signer_t is needed for init */ +#include "nostr_core/nostr_signer.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* The Nostr kind for skills (NIP-51 list kind variant, addressable). */ +#define AGENT_SKILL_KIND 31123 + +/* db_kv key for the selected skill d-tags (stored as a JSON array string). */ +#define AGENT_SKILLS_SELECTED_KEY "agent.selected_skills" + +/* Maximum number of requires_tool tags per skill. */ +#define AGENT_SKILL_MAX_TOOLS 32 + +/* ── Skill struct ──────────────────────────────────────────────────── */ + +typedef struct { + char d_tag[128]; /* unique identifier */ + char name[128]; /* skill name */ + char description[512]; /* short description */ + char pubkey[65]; /* author pubkey (hex) */ + char *content; /* system prompt template (owned, g_free) */ + char **requires_tools; /* array of tool name strings (owned, g_free) */ + int tool_count; +} agent_skill_t; + +/* ── Lifecycle ─────────────────────────────────────────────────────── */ + +/* + * Initialize the skills module. Stores the signer + pubkey references + * for publish/delete operations. + * + * signer — the user's nostr_signer_t (NULL for read-only/no-login) + * pubkey_hex — the user's hex pubkey (64 chars, may be NULL) + * + * Call after login. + */ +void agent_skills_init(nostr_signer_t *signer, const char *pubkey_hex); + +/* + * Update the signer reference (e.g. after switching identity). + */ +void agent_skills_set_signer(nostr_signer_t *signer, const char *pubkey_hex); + +/* ── Fetch / list ──────────────────────────────────────────────────── */ + +/* + * Fetch kind 31123 skill events from the local SQLite cache and parse + * them into skill structs. Skills are public events from any author. + * + * Returns a newly allocated cJSON array of skill summary objects: + * [{"d":"...", "name":"...", "description":"...", + * "requires_tools":["tool1","tool2"], + * "content":"...", "pubkey":"..."}, ...] + * Returns an empty array on error or if no skills exist. + * Caller must cJSON_Delete() the result. + */ +cJSON *agent_skills_fetch(void); + +/* + * Get the Sovereign Browser Skill — the default skill stored in local + * settings (not from Nostr). Returns a newly allocated cJSON object: + * {"d":"", "name":"...", "description":"...", "content":"...", + * "requires_tools":["tool1","tool2"], + * "unsaved":true, "pubkey":""} + * Caller must cJSON_Delete() the result. + */ +cJSON *agent_skills_get_default(void); + +/* + * Free an array of agent_skill_t structs (and each skill's owned fields). + * skills — the array (may be NULL) + * count — number of entries in the array + */ +void agent_skills_free_list(agent_skill_t *skills, int count); + +/* ── Selection (local settings) ────────────────────────────────────── */ + +/* + * Get the list of currently selected skill d-tags from local settings. + * + * Returns a newly allocated cJSON array of d-tag strings: + * ["d-tag-1", "d-tag-2", ...] + * Returns an empty array if none selected. Caller must cJSON_Delete(). + */ +cJSON *agent_skills_get_selected(void); + +/* + * Save the selected skill d-tags to local settings (db_kv). + * + * d_tags_json — a JSON array string of d-tags, e.g. "[\"a\",\"b\"]" + * + * Returns 0 on success, -1 on error. + */ +int agent_skills_set_selected(const char *d_tags_json); + +/* + * Toggle selection of a skill: add the d-tag if not selected, remove it + * if already selected. Saves the updated selection to local settings. + * + * d_tag — the skill's d-tag value + * + * Returns a newly allocated cJSON array of the updated selection + * (caller must cJSON_Delete()), or NULL on error. + */ +cJSON *agent_skills_toggle_selected(const char *d_tag); + +/* ── Build system prompt ───────────────────────────────────────────── */ + +/* + * Build the system prompt by concatenating the selected skills' + * templates in selection order. Templates are separated by + * "\n\n---\n\n". + * + * Returns a newly allocated string (caller must g_free), or NULL if no + * skills are selected (caller should fall back to the default prompt). + */ +char *agent_skills_build_system_prompt(void); + +/* ── Publish / delete ──────────────────────────────────────────────── */ + +/* + * Create and publish a new kind 31123 skill event. Signs with the + * user's key, publishes to bootstrap relays, and stores in SQLite. + * Skills are PUBLIC events — no NIP-44 encryption. + * + * name — skill name (also used to derive the d-tag) + * description — short description (may be NULL or "") + * content — the system prompt template (required) + * requires_tools_json — JSON array string of tool names, e.g. + * "[\"browser\",\"fs\"]" (may be NULL or "[]") + * + * Returns a newly allocated string with the d-tag of the new skill + * (caller must g_free), or NULL on error. + */ +char *agent_skills_publish(const char *name, + const char *description, + const char *content, + const char *requires_tools_json); + +/* + * Publish a kind 5 deletion event for a skill authored by the user, + * and remove it from the local SQLite cache. + * + * d_tag — the skill's d-tag value + * + * Returns 0 on success, -1 on error. + */ +int agent_skills_delete(const char *d_tag); + +/* + * Update an existing kind 31123 skill event with new content. Fetches + * the existing event by d-tag (authored by the user), updates its + * name/description/content/requires_tools, re-signs, and re-publishes. + * + * d_tag — the skill's d-tag value (identifies the skill) + * name — new skill name (may be NULL to keep existing) + * description — new description (may be NULL to keep existing) + * content — new system prompt template (required) + * requires_tools_json — JSON array string of tool names (may be NULL) + * + * Returns 0 on success, -1 on error. + */ +int agent_skills_update(const char *d_tag, + const char *name, + const char *description, + const char *content, + const char *requires_tools_json); + +#ifdef __cplusplus +} +#endif + +#endif /* AGENT_SKILLS_H */ diff --git a/src/agent_tool_catalog.h b/src/agent_tool_catalog.h new file mode 100644 index 0000000..e22cdff --- /dev/null +++ b/src/agent_tool_catalog.h @@ -0,0 +1,46 @@ +/* + * agent_tool_catalog.h — shared tool catalog definitions + * + * Exposes the tool_defs[] array, its count, and build_tools_list() + * so that both the MCP server (agent_mcp.c) and the embedded agent + * (agent_llm.c, future) can access the same tool catalog. + * + * The definitions live in agent_mcp.c; this header just makes them + * non-static and declares them here. + */ + +#ifndef AGENT_TOOL_CATALOG_H +#define AGENT_TOOL_CATALOG_H + +#include "cjson/cJSON.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ── Tool definition struct ────────────────────────────────────────── * + * Each entry has a name, human-readable description, and a pre-built + * JSON Schema string for the tool's input parameters. + */ +typedef struct { + const char *name; + const char *description; + const char *schema_json; /* pre-built JSON schema string */ +} mcp_tool_def_t; + +/* The shared tool catalog array and its length. */ +extern const mcp_tool_def_t tool_defs[]; +extern const int tool_defs_count; + +/* + * Build a cJSON array of tool definitions in the MCP tools/list format: + * [{"name":"...","description":"...","inputSchema":{...}}, ...] + * The caller frees the returned cJSON*. + */ +cJSON *build_tools_list(void); + +#ifdef __cplusplus +} +#endif + +#endif /* AGENT_TOOL_CATALOG_H */ diff --git a/src/agent_tools.c b/src/agent_tools.c index 98decfc..ca87b5d 100644 --- a/src/agent_tools.c +++ b/src/agent_tools.c @@ -11,6 +11,7 @@ #include "agent_login.h" #include "agent_snapshot.h" #include "agent_server.h" +#include "agent_fs_tools.h" #include "tab_manager.h" #include "settings.h" #include "search.h" @@ -72,11 +73,14 @@ static char *normalize_url(const char *input) { return search_build_search_url(input); } -/* ── Get active webview (or NULL if not logged in / no tabs) ──────── */ +/* ── Get active webview (or NULL if not logged in / no tabs) ──────── + * When the agent sidebar is open, the "active" webview (the one with + * focus) might be the sidebar chat page. Agent tools must always + * operate on the MAIN webview (the web page), so we use + * tab_manager_get_main_webview() which never returns the sidebar. */ static WebKitWebView *get_active_webview(void) { - tab_info_t *tab = tab_manager_get_active(); - return tab ? tab->webview : NULL; + return tab_manager_get_main_webview(); } /* ── Coordinate-based click via GDK event synthesis ────────────────── */ @@ -407,6 +411,29 @@ static cJSON *tool_switch_identity(cJSON *params) { /* ── Navigation tools ─────────────────────────────────────────────── */ +/* Idle callback data for main-thread webview navigation. WebKitGTK is + * NOT thread-safe — webkit_web_view_load_uri() must be called on the + * GTK main thread. The agent loop runs on a background thread, so we + * hop to the main thread via g_idle_add() to perform the navigation. + * (Issue 3: calling webkit_web_view_load_uri() directly from the + * agent-loop background thread caused an "Aborted" segfault.) */ +typedef struct { + WebKitWebView *webview; + char *url; +} load_uri_idle_t; + +static gboolean load_uri_idle(gpointer user_data) { + load_uri_idle_t *ctx = (load_uri_idle_t *)user_data; + if (ctx && ctx->webview && ctx->url) { + webkit_web_view_load_uri(ctx->webview, ctx->url); + } + if (ctx) { + g_free(ctx->url); + g_free(ctx); + } + return G_SOURCE_REMOVE; +} + static cJSON *tool_open(cJSON *params) { const char *url = get_string_param(params, "url"); if (!url || !url[0]) return make_error("MISSING_PARAM", "Provide 'url'"); @@ -414,24 +441,75 @@ static cJSON *tool_open(cJSON *params) { char *normalized = normalize_url(url); if (!normalized) return make_error("INVALID_URL", "Invalid URL"); - tab_info_t *tab = tab_manager_get_active(); - if (!tab) { + /* Always operate on the MAIN webview (the web page), never the + * sidebar chat webview. tab_manager_get_main_webview() returns + * tab->webview directly, never tab->sidebar_webview. */ + WebKitWebView *wv = tab_manager_get_main_webview(); + if (!wv) { g_free(normalized); return make_error("NO_TAB", "No active tab"); } - webkit_web_view_load_uri(tab->webview, normalized); + /* Dispatch the load to the GTK main thread. The agent loop (and + * the libsoup MCP thread) are not the main thread, so calling + * webkit_web_view_load_uri() directly would crash. g_idle_add() + * schedules the call on the default main context, which is run by + * the GTK main loop on the main thread. */ + load_uri_idle_t *ctx = g_new(load_uri_idle_t, 1); + ctx->webview = wv; + ctx->url = g_strdup(normalized); + g_idle_add(load_uri_idle, ctx); + cJSON *data = cJSON_CreateObject(); cJSON_AddStringToObject(data, "url", normalized); g_free(normalized); return make_success(data); } +/* Idle callback data for simple main-thread webview actions (back, + * forward, reload). Like tool_open, these must run on the GTK main + * thread because WebKitGTK is not thread-safe and the agent loop + * runs on a background thread. */ +typedef enum { + WEBVIEW_ACTION_BACK, + WEBVIEW_ACTION_FORWARD, + WEBVIEW_ACTION_RELOAD +} webview_action_t; + +typedef struct { + WebKitWebView *webview; + webview_action_t action; +} webview_action_idle_t; + +static gboolean webview_action_idle(gpointer user_data) { + webview_action_idle_t *ctx = (webview_action_idle_t *)user_data; + if (ctx && ctx->webview) { + switch (ctx->action) { + case WEBVIEW_ACTION_BACK: + if (webkit_web_view_can_go_back(ctx->webview)) + webkit_web_view_go_back(ctx->webview); + break; + case WEBVIEW_ACTION_FORWARD: + if (webkit_web_view_can_go_forward(ctx->webview)) + webkit_web_view_go_forward(ctx->webview); + break; + case WEBVIEW_ACTION_RELOAD: + webkit_web_view_reload_bypass_cache(ctx->webview); + break; + } + } + g_free(ctx); + return G_SOURCE_REMOVE; +} + static cJSON *tool_back(cJSON *params) { (void)params; WebKitWebView *wv = get_active_webview(); if (!wv) return make_error("NO_TAB", "No active tab"); - webkit_web_view_go_back(wv); + webview_action_idle_t *ctx = g_new(webview_action_idle_t, 1); + ctx->webview = wv; + ctx->action = WEBVIEW_ACTION_BACK; + g_idle_add(webview_action_idle, ctx); return make_success(NULL); } @@ -439,7 +517,10 @@ static cJSON *tool_forward(cJSON *params) { (void)params; WebKitWebView *wv = get_active_webview(); if (!wv) return make_error("NO_TAB", "No active tab"); - webkit_web_view_go_forward(wv); + webview_action_idle_t *ctx = g_new(webview_action_idle_t, 1); + ctx->webview = wv; + ctx->action = WEBVIEW_ACTION_FORWARD; + g_idle_add(webview_action_idle, ctx); return make_success(NULL); } @@ -447,7 +528,10 @@ static cJSON *tool_reload(cJSON *params) { (void)params; WebKitWebView *wv = get_active_webview(); if (!wv) return make_error("NO_TAB", "No active tab"); - webkit_web_view_reload_bypass_cache(wv); + webview_action_idle_t *ctx = g_new(webview_action_idle_t, 1); + ctx->webview = wv; + ctx->action = WEBVIEW_ACTION_RELOAD; + g_idle_add(webview_action_idle, ctx); return make_success(NULL); } @@ -4068,6 +4152,14 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) { return make_error("MISSING_TOOL", "No 'tool' field in request"); } + /* ── Filesystem & shell tools (work before login) ──────────── * + * These are system-level tools that give the agent direct access + * to the qube's filesystem and shell. They don't require a Nostr + * login, so we dispatch them before the login check below. */ + if (agent_fs_is_tool(tool_name)) { + return agent_fs_tools_dispatch(tool_name, params); + } + /* Check login requirement for known tools. */ gboolean requires_login = TRUE; gboolean is_login_tool = (strcmp(tool_name, "login_status") == 0 || diff --git a/src/db.c b/src/db.c index cddbfb1..b9b234f 100644 --- a/src/db.c +++ b/src/db.c @@ -16,6 +16,7 @@ #include #include #include +#include /* ── Global state ──────────────────────────────────────────────────── */ @@ -117,7 +118,25 @@ static const char *SCHEMA_SQL = "CREATE INDEX IF NOT EXISTS idx_event_tags_name_value " " ON event_tags(tag_name, tag_value);" "CREATE INDEX IF NOT EXISTS idx_event_tags_event_id " - " ON event_tags(event_id);"; + " ON event_tags(event_id);" + "CREATE TABLE IF NOT EXISTS agent_sessions (" + " id TEXT PRIMARY KEY," + " title TEXT," + " created_at INTEGER," + " updated_at INTEGER" + ");" + "CREATE TABLE IF NOT EXISTS agent_messages (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " session_id TEXT," + " role TEXT," + " content TEXT," + " tool_calls TEXT," + " tool_call_id TEXT," + " created_at INTEGER," + " FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE" + ");" + "CREATE INDEX IF NOT EXISTS idx_agent_messages_session " + " ON agent_messages(session_id, created_at);"; /* ── Init / Close ──────────────────────────────────────────────────── */ @@ -432,6 +451,90 @@ int db_count_events(const char *pubkey_hex, int kind) { return count; } +cJSON *db_get_events_by_kind(int kind, int limit) { + if (g_db == NULL) return NULL; + + sqlite3_stmt *stmt = NULL; + int lim = (limit > 0) ? limit : 10000; + + int rc = sqlite3_prepare_v2(g_db, + "SELECT raw_json FROM events " + "WHERE kind = ? " + "ORDER BY created_at DESC LIMIT ?;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) { + g_printerr("[db] Prepare get_events_by_kind failed: %s\n", + sqlite3_errmsg(g_db)); + return NULL; + } + + sqlite3_bind_int(stmt, 1, kind); + sqlite3_bind_int(stmt, 2, lim); + + cJSON *array = cJSON_CreateArray(); + while (sqlite3_step(stmt) == SQLITE_ROW) { + const char *json_str = (const char *)sqlite3_column_text(stmt, 0); + if (json_str) { + cJSON *event = cJSON_Parse(json_str); + if (event) { + cJSON_AddItemToArray(array, event); + } + } + } + sqlite3_finalize(stmt); + return array; +} + +int db_delete_event(const char *event_id) { + if (g_db == NULL || event_id == NULL) return -1; + + char *err = NULL; + int rc = sqlite3_exec(g_db, "BEGIN TRANSACTION;", NULL, NULL, &err); + if (rc != SQLITE_OK) { + if (err) sqlite3_free(err); + return -1; + } + + /* Delete tags first. */ + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(g_db, + "DELETE FROM event_tags WHERE event_id = ?;", + -1, &stmt, NULL); + if (rc == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_TRANSIENT); + sqlite3_step(stmt); + sqlite3_finalize(stmt); + } + + /* Delete the event row. */ + rc = sqlite3_prepare_v2(g_db, + "DELETE FROM events WHERE id = ?;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) { + g_printerr("[db] delete_event prepare failed: %s\n", + sqlite3_errmsg(g_db)); + sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL); + return -1; + } + sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + + if (rc != SQLITE_DONE) { + g_printerr("[db] delete_event step failed: %s\n", + sqlite3_errmsg(g_db)); + sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL); + return -1; + } + + rc = sqlite3_exec(g_db, "COMMIT;", NULL, NULL, &err); + if (rc != SQLITE_OK) { + if (err) sqlite3_free(err); + return -1; + } + return 0; +} + /* ── Key-Value store ───────────────────────────────────────────────── */ int db_kv_set(const char *key, const char *value) { @@ -762,3 +865,292 @@ int db_session_clear(void) { } return 0; } + +/* ── Agent chat sessions ───────────────────────────────────────────── */ + +char *db_agent_session_create(const char *title) { + if (g_db == NULL) return NULL; + + char *id = g_uuid_string_random(); + if (id == NULL) { + g_printerr("[db] agent_session_create: g_uuid_string_random failed\n"); + return NULL; + } + + long now = (long)time(NULL); + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(g_db, + "INSERT INTO agent_sessions (id, title, created_at, updated_at) " + "VALUES (?, ?, ?, ?);", + -1, &stmt, NULL); + if (rc != SQLITE_OK) { + g_printerr("[db] agent_session_create: prepare failed: %s\n", + sqlite3_errmsg(g_db)); + g_free(id); + return NULL; + } + + sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, title ? title : "", -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(stmt, 3, now); + sqlite3_bind_int64(stmt, 4, now); + + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + + if (rc != SQLITE_DONE) { + g_printerr("[db] agent_session_create: insert failed: %s\n", + sqlite3_errmsg(g_db)); + g_free(id); + return NULL; + } + + return id; +} + +char *db_agent_session_get_latest(void) { + if (g_db == NULL) return NULL; + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(g_db, + "SELECT id FROM agent_sessions ORDER BY updated_at DESC LIMIT 1;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) { + g_printerr("[db] agent_session_get_latest: prepare failed: %s\n", + sqlite3_errmsg(g_db)); + return NULL; + } + + char *result = NULL; + if (sqlite3_step(stmt) == SQLITE_ROW) { + const char *id = (const char *)sqlite3_column_text(stmt, 0); + if (id) { + result = g_strdup(id); + } + } + sqlite3_finalize(stmt); + return result; +} + +int db_agent_session_update(const char *session_id, const char *title) { + if (g_db == NULL || session_id == NULL) return -1; + + long now = (long)time(NULL); + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(g_db, + "UPDATE agent_sessions SET title = ?, updated_at = ? " + "WHERE id = ?;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) { + g_printerr("[db] agent_session_update: prepare failed: %s\n", + sqlite3_errmsg(g_db)); + return -1; + } + + sqlite3_bind_text(stmt, 1, title ? title : "", -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(stmt, 2, now); + sqlite3_bind_text(stmt, 3, session_id, -1, SQLITE_TRANSIENT); + + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + + if (rc != SQLITE_DONE) { + g_printerr("[db] agent_session_update: update failed: %s\n", + sqlite3_errmsg(g_db)); + return -1; + } + return 0; +} + +int db_agent_session_list(char ***ids_out, char ***titles_out, + int *count_out) { + if (g_db == NULL || ids_out == NULL || titles_out == NULL || + count_out == NULL) { + return -1; + } + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(g_db, + "SELECT id, title FROM agent_sessions " + "ORDER BY updated_at DESC;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) { + g_printerr("[db] agent_session_list: prepare failed: %s\n", + sqlite3_errmsg(g_db)); + return -1; + } + + /* First pass: count rows. */ + int count = 0; + while (sqlite3_step(stmt) == SQLITE_ROW) count++; + sqlite3_reset(stmt); + + /* Allocate arrays. */ + *ids_out = g_new0(char *, count); + *titles_out = g_new0(char *, count); + *count_out = 0; + + /* Second pass: fill. */ + int i = 0; + while (sqlite3_step(stmt) == SQLITE_ROW && i < count) { + const char *id = (const char *)sqlite3_column_text(stmt, 0); + const char *title = (const char *)sqlite3_column_text(stmt, 1); + (*ids_out)[i] = g_strdup(id ? id : ""); + (*titles_out)[i] = g_strdup(title ? title : ""); + i++; + } + sqlite3_finalize(stmt); + + *count_out = i; + return i; +} + +int db_agent_message_add(const char *session_id, const char *role, + const char *content, const char *tool_calls, + const char *tool_call_id) { + if (g_db == NULL || session_id == NULL || role == NULL) return -1; + + long now = (long)time(NULL); + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(g_db, + "INSERT INTO agent_messages " + "(session_id, role, content, tool_calls, tool_call_id, created_at) " + "VALUES (?, ?, ?, ?, ?, ?);", + -1, &stmt, NULL); + if (rc != SQLITE_OK) { + g_printerr("[db] agent_message_add: prepare failed: %s\n", + sqlite3_errmsg(g_db)); + return -1; + } + + sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, role, -1, SQLITE_TRANSIENT); + if (content) { + sqlite3_bind_text(stmt, 3, content, -1, SQLITE_TRANSIENT); + } else { + sqlite3_bind_null(stmt, 3); + } + if (tool_calls) { + sqlite3_bind_text(stmt, 4, tool_calls, -1, SQLITE_TRANSIENT); + } else { + sqlite3_bind_null(stmt, 4); + } + if (tool_call_id) { + sqlite3_bind_text(stmt, 5, tool_call_id, -1, SQLITE_TRANSIENT); + } else { + sqlite3_bind_null(stmt, 5); + } + sqlite3_bind_int64(stmt, 6, now); + + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + + if (rc != SQLITE_DONE) { + g_printerr("[db] agent_message_add: insert failed: %s\n", + sqlite3_errmsg(g_db)); + return -1; + } + + return (int)sqlite3_last_insert_rowid(g_db); +} + +cJSON *db_agent_message_list(const char *session_id) { + if (g_db == NULL || session_id == NULL) return NULL; + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(g_db, + "SELECT id, role, content, tool_calls, tool_call_id " + "FROM agent_messages WHERE session_id = ? " + "ORDER BY created_at ASC, id ASC;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) { + g_printerr("[db] agent_message_list: prepare failed: %s\n", + sqlite3_errmsg(g_db)); + return NULL; + } + + sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT); + + cJSON *array = cJSON_CreateArray(); + while (sqlite3_step(stmt) == SQLITE_ROW) { + cJSON *msg = cJSON_CreateObject(); + cJSON_AddNumberToObject(msg, "id", + (double)sqlite3_column_int64(stmt, 0)); + + const char *role = (const char *)sqlite3_column_text(stmt, 1); + cJSON_AddStringToObject(msg, "role", role ? role : ""); + + /* content may be NULL. */ + if (sqlite3_column_type(stmt, 2) == SQLITE_NULL) { + cJSON_AddNullToObject(msg, "content"); + } else { + const char *content = (const char *)sqlite3_column_text(stmt, 2); + cJSON_AddStringToObject(msg, "content", content ? content : ""); + } + + /* tool_calls may be NULL. */ + if (sqlite3_column_type(stmt, 3) == SQLITE_NULL) { + cJSON_AddNullToObject(msg, "tool_calls"); + } else { + const char *tc = (const char *)sqlite3_column_text(stmt, 3); + cJSON_AddStringToObject(msg, "tool_calls", tc ? tc : ""); + } + + /* tool_call_id may be NULL. */ + if (sqlite3_column_type(stmt, 4) == SQLITE_NULL) { + cJSON_AddNullToObject(msg, "tool_call_id"); + } else { + const char *tcid = (const char *)sqlite3_column_text(stmt, 4); + cJSON_AddStringToObject(msg, "tool_call_id", tcid ? tcid : ""); + } + + cJSON_AddItemToArray(array, msg); + } + sqlite3_finalize(stmt); + return array; +} + +int db_agent_session_delete(const char *session_id) { + if (g_db == NULL || session_id == NULL) return -1; + + /* Delete messages first (in case foreign keys are not enforced). */ + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(g_db, + "DELETE FROM agent_messages WHERE session_id = ?;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) { + g_printerr("[db] agent_session_delete: prepare messages failed: %s\n", + sqlite3_errmsg(g_db)); + return -1; + } + sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + g_printerr("[db] agent_session_delete: delete messages failed: %s\n", + sqlite3_errmsg(g_db)); + return -1; + } + + /* Delete the session row. */ + rc = sqlite3_prepare_v2(g_db, + "DELETE FROM agent_sessions WHERE id = ?;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) { + g_printerr("[db] agent_session_delete: prepare session failed: %s\n", + sqlite3_errmsg(g_db)); + return -1; + } + sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + g_printerr("[db] agent_session_delete: delete session failed: %s\n", + sqlite3_errmsg(g_db)); + return -1; + } + return 0; +} diff --git a/src/db.h b/src/db.h index 15f895e..1d02467 100644 --- a/src/db.h +++ b/src/db.h @@ -95,6 +95,24 @@ cJSON *db_get_events(const char *pubkey_hex, int kind, int limit); */ int db_count_events(const char *pubkey_hex, int kind); +/* + * Fetch all events of a given kind from ALL authors (no pubkey filter), + * newest first. Used for public events like kind 31123 skills that are + * authored by anyone. + * + * limit — max number of events (0 = no limit) + * + * Returns a cJSON array of event objects. Caller must cJSON_Delete(). + * Returns NULL on error. + */ +cJSON *db_get_events_by_kind(int kind, int limit); + +/* + * Delete a single event by its event id (and its tags, via cascade). + * Returns 0 on success, -1 on error. + */ +int db_delete_event(const char *event_id); + /* ── Key-Value store ───────────────────────────────────────────────── */ /* @@ -196,6 +214,60 @@ int db_session_load(char ***urls_out, char ***titles_out, int *count_out); */ int db_session_clear(void); +/* ── Agent chat sessions ───────────────────────────────────────────── */ + +/* + * Create a new chat session. Returns the session ID (newly allocated string, + * caller must g_free). Returns NULL on error. + */ +char *db_agent_session_create(const char *title); + +/* + * Get the most recent chat session ID. Returns a newly allocated string, + * or NULL if no sessions exist. Caller must g_free. + */ +char *db_agent_session_get_latest(void); + +/* + * Update a session's title and updated_at timestamp. + * Returns 0 on success, -1 on error. + */ +int db_agent_session_update(const char *session_id, const char *title); + +/* + * List all chat sessions, newest first. Fills arrays with session IDs and + * titles. Caller must free each string and the arrays. + * Returns the number of sessions, or -1 on error. + */ +int db_agent_session_list(char ***ids_out, char ***titles_out, + int *count_out); + +/* + * Add a message to a chat session. + * role — "user", "assistant", "tool", or "system" + * content — message text (may be NULL for assistant msgs with only tool_calls) + * tool_calls — JSON string of tool calls array (may be NULL) + * tool_call_id — for role="tool", the ID of the tool call this responds to (may be NULL) + * Returns the message row ID (>0) on success, -1 on error. + */ +int db_agent_message_add(const char *session_id, const char *role, + const char *content, const char *tool_calls, + const char *tool_call_id); + +/* + * Load all messages for a session, ordered by created_at (oldest first). + * Returns a cJSON array of message objects, each with: + * {"id":N, "role":"...", "content":"...", "tool_calls":"...", "tool_call_id":"..."} + * Returns NULL on error. Caller must cJSON_Delete(). + */ +cJSON *db_agent_message_list(const char *session_id); + +/* + * Delete a chat session and all its messages. + * Returns 0 on success, -1 on error. + */ +int db_agent_session_delete(const char *session_id); + #ifdef __cplusplus } #endif diff --git a/src/main.c b/src/main.c index f8b6c67..d0318ef 100644 --- a/src/main.c +++ b/src/main.c @@ -50,6 +50,8 @@ #include "profile.h" #include "relay_fetch.h" #include "bookmarks.h" +#include "agent_conversations.h" +#include "agent_skills.h" #include "nostr_core/nostr_core.h" /* ---- Global state --------------------------------------------------- * @@ -89,6 +91,8 @@ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex, /* Update modules that hold a signer reference. */ settings_sync_set_signer(signer, g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL); + agent_conversations_set_signer(signer, + g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL); /* If this is the first login (we're still on global.db), switch to * the per-user profile database. If we're already on a per-user db @@ -110,6 +114,7 @@ void app_clear_signer(void) { g_logged_in = FALSE; settings_sync_set_signer(NULL, NULL); + agent_conversations_set_signer(NULL, NULL); } nostr_signer_t *app_get_signer(void) { return g_state.signer; } @@ -253,6 +258,22 @@ void on_menu_profile(GtkMenuItem *item, gpointer data) { } } +/* The hamburger-menu "Agent Setup…" item navigates the active tab to + * the sovereign://agents internal page, which renders the agent provider + * configuration UI and a link to open the agent chat. */ +void on_menu_agent(GtkMenuItem *item, gpointer data) { + (void)item; + (void)data; + + tab_info_t *tab = tab_manager_get_active(); + if (tab && tab->webview) { + webkit_web_view_load_uri(tab->webview, "sovereign://agents"); + } else { + /* No active tab — open a new one pointed at the agent page. */ + tab_manager_new_tab("sovereign://agents"); + } +} + /* ---- Keyboard shortcuts --------------------------------------------- * * All browser-level shortcuts are configurable via the shortcuts module. * The on_key_press handler looks up the action for the pressed key @@ -361,6 +382,10 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event, tab_manager_toggle_inspector(); return TRUE; + case SHORTCUT_TOGGLE_SIDEBAR: + tab_manager_toggle_sidebar(); + return TRUE; + default: return FALSE; } @@ -742,6 +767,18 @@ int main(int argc, char **argv) { settings_sync_init(g_state.signer, g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL); + /* Initialize the conversation persistence module (kind 30078 + * conversations). In no-login/read-only mode, the signer is NULL + * so conversations are not encrypted/synced. */ + agent_conversations_init(g_state.signer, + g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL); + + /* Initialize the skills module (kind 31123 public skill events). + * In no-login/read-only mode, the signer is NULL so skills cannot + * be published/deleted (but can still be fetched and selected). */ + agent_skills_init(g_state.signer, + g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL); + /* Set the user's avatar on the tab bar from their kind 0 profile. * If the profile hasn't been fetched yet (relay fetch is still * running), this shows the default icon. The avatar will be updated diff --git a/src/nostr_bridge.c b/src/nostr_bridge.c index 6b303bd..f686b91 100644 --- a/src/nostr_bridge.c +++ b/src/nostr_bridge.c @@ -24,6 +24,13 @@ #include "db.h" #include "bookmarks.h" #include "search.h" +#include "agent_chat.h" +#include "agent_loop.h" +#include "agent_chat_store.h" +#include "agent_conversations.h" +#include "agent_skills.h" +#include "agent_llm.h" +#include "embedded_web_content.h" /* ── Global bridge state ────────────────────────────────────────── * * The URI scheme callback needs access to the current signer. We keep @@ -119,6 +126,24 @@ static void respond_bytes(WebKitURISchemeRequest *request, GBytes *bytes, g_object_unref(stream); } +/* Serve an embedded web file (from src/embedded_web_content.c, generated + * by embed_web_files.sh from www/) by path, e.g. "agents/chat.html". + * Used by the sovereign://agents/chat* routes. */ +static void serve_embedded_file(WebKitURISchemeRequest *request, + const char *path) { + const embedded_file_t *f = get_embedded_file(path); + if (f == NULL) { + respond_error_json(request, -1, "Embedded file not found"); + return; + } + /* respond_bytes() takes ownership of the GBytes (unrefs it), so we + * must not unref it here. g_bytes_new_static() does not copy the + * data — it references the static embedded array, which lives for + * the lifetime of the binary. */ + GBytes *bytes = g_bytes_new_static(f->data, f->size); + respond_bytes(request, bytes, f->mime_type); +} + /* ── Method handlers ────────────────────────────────────────────── */ static void handle_get_public_key(WebKitURISchemeRequest *request) { @@ -1718,6 +1743,1490 @@ static void handle_bookmarks_deletedir(WebKitURISchemeRequest *request, g_free(dir); } +/* ── Agent pages (sovereign://agents) ───────────────────────────── */ + +/* Generate the sovereign://agents provider config HTML page. + * + * Two-section layout: + * 1. Provider management (shared via global.agent.providers): + * - Provider dropdown (select active provider) + * - Edit fields for the selected provider (name, base_url, api_key) + * - Add / Remove provider buttons + * - Refresh Models button (fetches models from the provider API) + * 2. Agent config (per-app, sovereign_browser.agent): + * - Model dropdown (from the active provider's model list) + * - System prompt textarea + * - Max iterations + * + * On submit, JS calls sovereign://agents/set?key=...&value=... for each + * field, which updates the in-memory settings, saves to SQLite, and + * triggers a debounced NIP-78 sync to Nostr. */ +static void handle_agents_page(WebKitURISchemeRequest *request) { + const browser_settings_t *bs = settings_get(); + + /* Build the provider dropdown options. */ + GString *provider_opts = g_string_new(""); + for (int i = 0; i < bs->agent_provider_count; i++) { + char *esc_name = g_markup_escape_text(bs->agent_providers[i].name, -1); + g_string_append_printf(provider_opts, + " \n", + esc_name, + (i == bs->agent_active_provider) ? " selected" : "", + esc_name); + g_free(esc_name); + } + + /* Active provider fields. */ + const agent_provider_t *ap = + (bs->agent_active_provider >= 0 && + bs->agent_active_provider < bs->agent_provider_count) + ? &bs->agent_providers[bs->agent_active_provider] + : NULL; + + char *esc_pname = g_markup_escape_text(ap ? ap->name : "", -1); + char *esc_base_url = g_markup_escape_text( + ap ? ap->base_url : bs->agent_llm_base_url, -1); + char *esc_api_key = g_markup_escape_text( + ap ? ap->api_key : bs->agent_llm_api_key, -1); + char *esc_model = g_markup_escape_text(bs->agent_llm_model, -1); + char *esc_prompt = g_markup_escape_text( + bs->agent_llm_system_prompt[0] ? bs->agent_llm_system_prompt + : SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT, -1); + + /* Build model options from the active provider's model list. */ + GString *model_opts = g_string_new(""); + if (ap) { + for (int m = 0; m < ap->model_count; m++) { + char *esc_m = g_markup_escape_text(ap->models[m], -1); + g_string_append_printf(model_opts, + " \n", + esc_m, + (strcmp(ap->models[m], bs->agent_llm_model) == 0) ? " selected" : "", + esc_m); + g_free(esc_m); + } + } + + char *head = sovereign_page_head("Agent"); + char *foot = sovereign_page_foot(); + char *html = g_strdup_printf( + "%s" + "

Agent

\n" + "

Configure the LLM provider for the embedded agent. " + "Providers are shared across all apps (sovereign_browser, client, " + "didactyl) via the Nostr d:user-settings event. " + "The model, system prompt, and max iterations are per-app. " + "Type a semicolon then a message in the URL bar to talk to the agent.

\n" + "\n" + "

Providers (shared)

\n" + "\n" + "
\n" + "
Active provider
\n" + "
\n" + "
\n" + "\n" + "
\n" + "
Provider name
\n" + "
\n" + "
\n" + "
\n" + "\n" + "
\n" + "
Base URL
\n" + "
\n" + "
\n" + "
\n" + "\n" + "
\n" + "
API Key
\n" + "
\n" + " \n" + "
\n" + "
\n" + "\n" + "
\n" + "
Manage providers
\n" + "
\n" + " \n" + "
\n" + "
\n" + "\n" + "

Agent Config (per-app)

\n" + "\n" + "
\n" + "
Model
\n" + "
\n" + " \n" + "
\n" + "
\n" + "\n" + "
\n" + "
Max iterations
\n" + "
\n" + "
\n" + "
\n" + "\n" + "

System Prompt

\n" + "
\n" + "
Custom system prompt (empty = default)
\n" + "
\n" + "
\n" + "
\n" + "\n" + "

Open Agent Chat

\n" + "\n" + "
\n" + "\n" + "\n" + "%s", + head, + provider_opts->str, + esc_pname, + esc_base_url, esc_api_key, + model_opts->str, esc_model, + bs->agent_max_iterations, esc_prompt, esc_model, foot); + + respond_html(request, html); + g_free(html); g_free(head); g_free(foot); + g_free(esc_pname); + g_free(esc_base_url); g_free(esc_api_key); + g_free(esc_model); g_free(esc_prompt); + g_string_free(provider_opts, TRUE); + g_string_free(model_opts, TRUE); +} + +/* Handle sovereign://agents/set?key=...&value=... + * Maps keys to settings: + * agent.llm_base_url — updates active provider's base_url (global.agent) + * agent.llm_api_key — updates active provider's api_key (global.agent) + * agent.llm_model — updates sovereign_browser.agent.model + * agent.llm_system_prompt — updates sovereign_browser.agent.skill_template (legacy alias) + * agent.skill_name — updates sovereign_browser.agent.skill_name + * agent.skill_description — updates sovereign_browser.agent.skill_description + * agent.skill_template — updates sovereign_browser.agent.skill_template + * agent.skill_requires_tools — updates sovereign_browser.agent.skill_requires_tools + * agent.max_iterations — updates sovereign_browser.agent.max_iterations + * agent.provider — selects the active provider by name + * agent.provider_add — adds a new provider (value = name) + * agent.provider_remove — removes a provider by name + * agent.provider_name — renames the active provider + * agent.provider_base_url — sets base_url for a named provider (value=name|url) + * agent.provider_api_key — sets api_key for a named provider (value=name|key) + * + * Saves via db_kv_set() / settings_save(), updates the global settings + * struct, and triggers a debounced NIP-78 sync publish. */ +static void handle_agents_set(WebKitURISchemeRequest *request, + const char *query) { + char *key = query_param(query, "key"); + char *value = query_param(query, "value"); + + if (key == NULL || value == NULL) { + respond_error_json(request, -1, "Missing key or value parameter"); + g_free(key); + g_free(value); + return; + } + + browser_settings_t *bs = settings_get_mutable(); + int need_sync = 1; + + if (strcmp(key, "agent.llm_base_url") == 0) { + /* Update the active provider's base_url + the resolved field. */ + snprintf(bs->agent_llm_base_url, + sizeof(bs->agent_llm_base_url), "%s", value); + db_kv_set("agent.llm_base_url", value); + if (bs->agent_active_provider >= 0 && + bs->agent_active_provider < bs->agent_provider_count) { + snprintf(bs->agent_providers[bs->agent_active_provider].base_url, + sizeof(bs->agent_providers[bs->agent_active_provider].base_url), + "%s", value); + } + } else if (strcmp(key, "agent.llm_api_key") == 0) { + /* Update the active provider's api_key + the resolved field. */ + snprintf(bs->agent_llm_api_key, + sizeof(bs->agent_llm_api_key), "%s", value); + db_kv_set("agent.llm_api_key", value); + if (bs->agent_active_provider >= 0 && + bs->agent_active_provider < bs->agent_provider_count) { + snprintf(bs->agent_providers[bs->agent_active_provider].api_key, + sizeof(bs->agent_providers[bs->agent_active_provider].api_key), + "%s", value); + } + } else if (strcmp(key, "agent.llm_model") == 0) { + snprintf(bs->agent_llm_model, + sizeof(bs->agent_llm_model), "%s", value); + db_kv_set("agent.llm_model", value); + } else if (strcmp(key, "agent.llm_system_prompt") == 0) { + /* Legacy alias — writes to agent_skill_template and keeps + * agent_llm_system_prompt in sync. */ + g_strlcpy(bs->agent_skill_template, value, + sizeof(bs->agent_skill_template)); + g_strlcpy(bs->agent_llm_system_prompt, value, + sizeof(bs->agent_llm_system_prompt)); + db_kv_set("agent.llm_system_prompt", value); + db_kv_set("agent.skill_template", value); + } else if (strcmp(key, "agent.skill_name") == 0) { + snprintf(bs->agent_skill_name, + sizeof(bs->agent_skill_name), "%s", value); + db_kv_set("agent.skill_name", value); + } else if (strcmp(key, "agent.skill_description") == 0) { + snprintf(bs->agent_skill_description, + sizeof(bs->agent_skill_description), "%s", value); + db_kv_set("agent.skill_description", value); + } else if (strcmp(key, "agent.skill_template") == 0) { + g_strlcpy(bs->agent_skill_template, value, + sizeof(bs->agent_skill_template)); + /* Keep the legacy alias in sync (truncates to 4096). */ + g_strlcpy(bs->agent_llm_system_prompt, value, + sizeof(bs->agent_llm_system_prompt)); + db_kv_set("agent.skill_template", value); + db_kv_set("agent.llm_system_prompt", value); + } else if (strcmp(key, "agent.skill_requires_tools") == 0) { + snprintf(bs->agent_skill_requires_tools, + sizeof(bs->agent_skill_requires_tools), "%s", value); + db_kv_set("agent.skill_requires_tools", value); + } else if (strcmp(key, "agent.max_iterations") == 0) { + int v = atoi(value); + if (v < 1) v = 1; + bs->agent_max_iterations = v; + db_kv_set("agent.max_iterations", value); + } else if (strcmp(key, "agent.provider") == 0) { + /* Select the active provider by name. */ + int found = -1; + for (int i = 0; i < bs->agent_provider_count; i++) { + if (strcmp(bs->agent_providers[i].name, value) == 0) { + found = i; + break; + } + } + if (found >= 0) { + bs->agent_active_provider = found; + snprintf(bs->agent_active_provider_name, + sizeof(bs->agent_active_provider_name), "%s", value); + snprintf(bs->agent_llm_base_url, sizeof(bs->agent_llm_base_url), + "%s", bs->agent_providers[found].base_url); + snprintf(bs->agent_llm_api_key, sizeof(bs->agent_llm_api_key), + "%s", bs->agent_providers[found].api_key); + db_kv_set("agent.provider", value); + db_kv_set("agent.llm_base_url", bs->agent_llm_base_url); + db_kv_set("agent.llm_api_key", bs->agent_llm_api_key); + } else { + respond_error_json(request, -1, "Provider not found"); + g_free(key); + g_free(value); + return; + } + } else if (strcmp(key, "agent.provider_add") == 0) { + /* Add a new provider with the given name. */ + if (bs->agent_provider_count >= SETTINGS_AGENT_MAX_PROVIDERS) { + respond_error_json(request, -1, "Max providers reached"); + g_free(key); + g_free(value); + return; + } + /* Check for duplicate name. */ + for (int i = 0; i < bs->agent_provider_count; i++) { + if (strcmp(bs->agent_providers[i].name, value) == 0) { + respond_error_json(request, -1, "Provider already exists"); + g_free(key); + g_free(value); + return; + } + } + agent_provider_t *p = &bs->agent_providers[bs->agent_provider_count]; + memset(p, 0, sizeof(*p)); + snprintf(p->name, sizeof(p->name), "%s", value); + bs->agent_provider_count++; + } else if (strcmp(key, "agent.provider_remove") == 0) { + /* Remove a provider by name. */ + int idx = -1; + for (int i = 0; i < bs->agent_provider_count; i++) { + if (strcmp(bs->agent_providers[i].name, value) == 0) { + idx = i; + break; + } + } + if (idx < 0) { + respond_error_json(request, -1, "Provider not found"); + g_free(key); + g_free(value); + return; + } + if (bs->agent_provider_count <= 1) { + respond_error_json(request, -1, "Cannot remove last provider"); + g_free(key); + g_free(value); + return; + } + /* Shift remaining providers down. */ + for (int i = idx; i < bs->agent_provider_count - 1; i++) { + bs->agent_providers[i] = bs->agent_providers[i + 1]; + } + bs->agent_provider_count--; + memset(&bs->agent_providers[bs->agent_provider_count], 0, + sizeof(bs->agent_providers[bs->agent_provider_count])); + /* Fix active provider index if needed. */ + if (bs->agent_active_provider >= bs->agent_provider_count) { + bs->agent_active_provider = 0; + snprintf(bs->agent_active_provider_name, + sizeof(bs->agent_active_provider_name), "%s", + bs->agent_providers[0].name); + snprintf(bs->agent_llm_base_url, sizeof(bs->agent_llm_base_url), + "%s", bs->agent_providers[0].base_url); + snprintf(bs->agent_llm_api_key, sizeof(bs->agent_llm_api_key), + "%s", bs->agent_providers[0].api_key); + db_kv_set("agent.provider", bs->agent_active_provider_name); + db_kv_set("agent.llm_base_url", bs->agent_llm_base_url); + db_kv_set("agent.llm_api_key", bs->agent_llm_api_key); + } + } else if (strcmp(key, "agent.provider_name") == 0) { + /* Rename the active provider. */ + if (bs->agent_active_provider >= 0 && + bs->agent_active_provider < bs->agent_provider_count) { + snprintf(bs->agent_providers[bs->agent_active_provider].name, + sizeof(bs->agent_providers[bs->agent_active_provider].name), + "%s", value); + snprintf(bs->agent_active_provider_name, + sizeof(bs->agent_active_provider_name), "%s", value); + db_kv_set("agent.provider", value); + } + } else if (strcmp(key, "agent.provider_base_url") == 0 || + strcmp(key, "agent.provider_api_key") == 0) { + /* Set base_url or api_key for a named provider. + * value format: "provider_name|new_value" */ + char *sep = strchr(value, '|'); + if (sep == NULL) { + respond_error_json(request, -1, + "Expected provider_name|value"); + g_free(key); + g_free(value); + return; + } + *sep = '\0'; + const char *pname = value; + const char *pval = sep + 1; + int idx = -1; + for (int i = 0; i < bs->agent_provider_count; i++) { + if (strcmp(bs->agent_providers[i].name, pname) == 0) { + idx = i; + break; + } + } + if (idx < 0) { + respond_error_json(request, -1, "Provider not found"); + g_free(key); + g_free(value); + return; + } + if (strcmp(key, "agent.provider_base_url") == 0) { + snprintf(bs->agent_providers[idx].base_url, + sizeof(bs->agent_providers[idx].base_url), "%s", pval); + if (idx == bs->agent_active_provider) { + snprintf(bs->agent_llm_base_url, + sizeof(bs->agent_llm_base_url), "%s", pval); + db_kv_set("agent.llm_base_url", pval); + } + } else { + snprintf(bs->agent_providers[idx].api_key, + sizeof(bs->agent_providers[idx].api_key), "%s", pval); + if (idx == bs->agent_active_provider) { + snprintf(bs->agent_llm_api_key, + sizeof(bs->agent_llm_api_key), "%s", pval); + db_kv_set("agent.llm_api_key", pval); + } + } + } else { + respond_error_json(request, -1, "Unknown agent key"); + g_free(key); + g_free(value); + return; + } + + settings_save(); + g_print("[agents] %s = %s\n", key, value); + + /* Trigger debounced NIP-78 sync to Nostr (d:user-settings). */ + if (need_sync) { + settings_sync_publish(); + } + + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "ok"); + cJSON_AddStringToObject(result, "key", key); + cJSON_AddStringToObject(result, "value", value); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); + g_free(key); + g_free(value); +} + +/* Handle sovereign://agents/models?base_url=...&api_key=... + * Fetches the list of available models from the LLM API and returns + * them as a JSON array of strings: ["model1", "model2", ...]. + * Query params base_url and api_key override the saved settings (so + * the dropdown can be populated before the user clicks Save). Falls + * back to saved settings if a param is missing. On error, returns + * {"error": "message"}. */ +static void handle_agents_models(WebKitURISchemeRequest *request, + const char *query) { + char *q_base_url = query_param(query, "base_url"); + char *q_api_key = query_param(query, "api_key"); + + const browser_settings_t *bs = settings_get(); + const char *base_url = (q_base_url && q_base_url[0]) ? q_base_url : bs->agent_llm_base_url; + const char *api_key = (q_api_key && q_api_key[0]) ? q_api_key : bs->agent_llm_api_key; + + if (base_url == NULL || base_url[0] == '\0') { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "No base URL configured"); + char *json = cJSON_PrintUnformatted(err); + cJSON_Delete(err); + respond_json(request, json); + free(json); + g_free(q_base_url); + g_free(q_api_key); + return; + } + + cJSON *models = agent_llm_list_models(base_url, api_key); + + char *json = NULL; + if (models != NULL) { + json = cJSON_PrintUnformatted(models); + cJSON_Delete(models); + } else { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", + "Failed to fetch models (check base URL and API key)"); + json = cJSON_PrintUnformatted(err); + cJSON_Delete(err); + } + + respond_json(request, json); + free(json); + g_free(q_base_url); + g_free(q_api_key); +} + + +/* Handle sovereign://agents/messages -- return message history as JSON. */ +static void handle_agents_messages(WebKitURISchemeRequest *request) { + cJSON *msgs = agent_chat_store_get_messages(); + if (msgs == NULL) { + /* No session exists -- return an empty array. */ + respond_json(request, "[]"); + return; + } + char *json = cJSON_PrintUnformatted(msgs); + cJSON_Delete(msgs); + respond_json(request, json); + free(json); +} + +/* Handle sovereign://agents/send?text=... -- send a user message and start + * the agent loop. Returns immediately (agent_loop_run spawns a background + * thread). Returns an error if the agent is already running. */ +static void handle_agents_send(WebKitURISchemeRequest *request, + const char *query) { + char *text = query_param(query, "text"); + if (text == NULL || text[0] == '\0') { + respond_error_json(request, -1, "Missing text parameter"); + g_free(text); + return; + } + + if (agent_loop_is_running()) { + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "error"); + cJSON_AddStringToObject(result, "message", + "Agent is already running"); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); + g_free(text); + return; + } + + int rc = agent_loop_run(text); + g_free(text); + + if (rc != 0) { + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "error"); + cJSON_AddStringToObject(result, "message", + "Failed to start agent loop"); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); + return; + } + + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "started"); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); +} + +/* Handle sovereign://agents/status -- return current agent loop status as JSON. */ +static void handle_agents_status(WebKitURISchemeRequest *request) { + agent_loop_state_t state; + int iteration = 0; + char *current_tool = NULL; + char *last_message = NULL; + char *error = NULL; + + agent_loop_get_status(&state, &iteration, ¤t_tool, + &last_message, &error); + + const char *state_str = "idle"; + switch (state) { + case AGENT_LOOP_IDLE: state_str = "idle"; break; + case AGENT_LOOP_THINKING: state_str = "thinking"; break; + case AGENT_LOOP_TOOL_CALL: state_str = "tool_call"; break; + case AGENT_LOOP_COMPLETE: state_str = "complete"; break; + case AGENT_LOOP_ERROR: state_str = "error"; break; + case AGENT_LOOP_CANCELLED: state_str = "cancelled"; break; + } + + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "state", state_str); + cJSON_AddNumberToObject(result, "iteration", iteration); + if (current_tool) { + cJSON_AddStringToObject(result, "current_tool", current_tool); + } else { + cJSON_AddNullToObject(result, "current_tool"); + } + if (last_message) { + cJSON_AddStringToObject(result, "last_message", last_message); + } else { + cJSON_AddNullToObject(result, "last_message"); + } + if (error) { + cJSON_AddStringToObject(result, "error", error); + } else { + cJSON_AddNullToObject(result, "error"); + } + + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); + g_free(current_tool); + g_free(last_message); + g_free(error); +} + +/* Handle sovereign://agents/cancel -- cancel the running agent loop. */ +static void handle_agents_cancel(WebKitURISchemeRequest *request) { + agent_loop_cancel(); + + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "cancelled"); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); +} + +/* ── Conversation persistence endpoints ──────────────────────────── */ + +/* Handle sovereign://agents/conversations — list saved conversations. */ +static void handle_agents_conversations(WebKitURISchemeRequest *request) { + cJSON *list = agent_conversations_list(); + if (list == NULL) { + respond_json(request, "[]"); + return; + } + char *json = cJSON_PrintUnformatted(list); + cJSON_Delete(list); + respond_json(request, json); + free(json); +} + +/* Handle sovereign://agents/conversations/new[?title=...] — create a new + * chat session. Returns {"id":"...", "title":"..."}. Also saves the empty + * conversation to Nostr immediately so it appears in the conversation + * list right away (matching ai.html's behavior). The optional title query + * parameter sets the conversation title; if absent, "New Chat" is used. */ +static void handle_agents_conversations_new(WebKitURISchemeRequest *request, + const char *query) { + char *title_param = query_param(query, "title"); + const char *title = (title_param && title_param[0]) ? title_param : "New Chat"; + + const char *new_sid = agent_chat_store_new_session(title); + if (new_sid == NULL) { + respond_error_json(request, -1, "Failed to create new session"); + g_free(title_param); + return; + } + + /* Save the empty conversation to Nostr immediately so it shows up + * in the conversation list. agent_conversations_save() uses the + * current session's messages (empty at this point). We pass the + * new session id as the conversation id so the d-tag is stable. */ + char *saved_id = agent_conversations_save(new_sid, title); + /* saved_id may differ from new_sid if save generated a new id, but + * since we passed new_sid it should match. Free either way. */ + if (saved_id) g_free(saved_id); + + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "id", new_sid); + cJSON_AddStringToObject(result, "title", title); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); + g_free(title_param); +} + +/* Handle sovereign://agents/conversations/load?id=... — load a conversation + * from Nostr into the agent chat store. Returns {"status":"ok"}. */ +static void handle_agents_conversations_load(WebKitURISchemeRequest *request, + const char *query) { + char *id = query_param(query, "id"); + if (id == NULL || id[0] == '\0') { + respond_error_json(request, -1, "Missing id parameter"); + g_free(id); + return; + } + + int rc = agent_conversations_load(id); + g_free(id); + + if (rc != 0) { + respond_error_json(request, -1, "Failed to load conversation"); + return; + } + + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "ok"); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); +} + +/* Handle sovereign://agents/conversations/delete?id=... — delete a + * conversation from Nostr. Returns {"status":"deleted"}. */ +static void handle_agents_conversations_delete(WebKitURISchemeRequest *request, + const char *query) { + char *id = query_param(query, "id"); + if (id == NULL || id[0] == '\0') { + respond_error_json(request, -1, "Missing id parameter"); + g_free(id); + return; + } + + int rc = agent_conversations_delete(id); + g_free(id); + + if (rc != 0) { + respond_error_json(request, -1, "Failed to delete conversation"); + return; + } + + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "deleted"); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); +} + +/* Handle sovereign://agents/conversations/save?id=...&title=... — save the + * current conversation to Nostr. Returns {"status":"saved","id":"..."}. */ +static void handle_agents_conversations_save(WebKitURISchemeRequest *request, + const char *query) { + char *id = query_param(query, "id"); + char *title = query_param(query, "title"); + + char *saved_id = agent_conversations_save(id, title); + g_free(id); + g_free(title); + + if (saved_id == NULL) { + respond_error_json(request, -1, "Failed to save conversation"); + return; + } + + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "saved"); + cJSON_AddStringToObject(result, "id", saved_id); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); + g_free(saved_id); +} + +/* Handle sovereign://agents/conversations/rename?id=...&title=... — rename + * a conversation (update title in SQLite cache + re-publish kind 30078). + * Returns {"status":"renamed"}. */ +static void handle_agents_conversations_rename(WebKitURISchemeRequest *request, + const char *query) { + char *id = query_param(query, "id"); + char *title = query_param(query, "title"); + + if (id == NULL || id[0] == '\0') { + respond_error_json(request, -1, "Missing id parameter"); + g_free(id); + g_free(title); + return; + } + if (title == NULL || title[0] == '\0') { + respond_error_json(request, -1, "Missing title parameter"); + g_free(id); + g_free(title); + return; + } + + int rc = agent_conversations_rename(id, title); + g_free(id); + g_free(title); + + if (rc != 0) { + respond_error_json(request, -1, "Failed to rename conversation"); + return; + } + + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "renamed"); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); +} + +/* ── Skills endpoints (kind 31123) ─────────────────────────────── */ + +/* Handle sovereign://agents/skills — list available skills as JSON. */ +static void handle_agents_skills(WebKitURISchemeRequest *request) { + cJSON *list = agent_skills_fetch(); + if (list == NULL) { + respond_json(request, "[]"); + return; + } + char *json = cJSON_PrintUnformatted(list); + cJSON_Delete(list); + respond_json(request, json); + free(json); +} + +/* Handle sovereign://agents/skills/selected — return selected d-tags. */ +static void handle_agents_skills_selected(WebKitURISchemeRequest *request) { + cJSON *selected = agent_skills_get_selected(); + if (selected == NULL) { + respond_json(request, "[]"); + return; + } + char *json = cJSON_PrintUnformatted(selected); + cJSON_Delete(selected); + respond_json(request, json); + free(json); +} + +/* Handle sovereign://agents/skills/select?d=... — toggle selection. */ +static void handle_agents_skills_select(WebKitURISchemeRequest *request, + const char *query) { + char *d = query_param(query, "d"); + if (d == NULL || d[0] == '\0') { + respond_error_json(request, -1, "Missing d parameter"); + g_free(d); + return; + } + + cJSON *selected = agent_skills_toggle_selected(d); + g_free(d); + + if (selected == NULL) { + respond_error_json(request, -1, "Failed to toggle skill selection"); + return; + } + + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "ok"); + cJSON_AddItemToObject(result, "selected", selected); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); +} + +/* Handle sovereign://agents/skills/create?name=...&description=... + * &content=...&requires_tools=... — create and publish a new skill. */ +static void handle_agents_skills_create(WebKitURISchemeRequest *request, + const char *query) { + char *name = query_param(query, "name"); + char *desc = query_param(query, "description"); + char *content = query_param(query, "content"); + char *tools = query_param(query, "requires_tools"); + + if (name == NULL || name[0] == '\0') { + respond_error_json(request, -1, "Missing name parameter"); + g_free(name); g_free(desc); g_free(content); g_free(tools); + return; + } + if (content == NULL || content[0] == '\0') { + respond_error_json(request, -1, "Missing content parameter"); + g_free(name); g_free(desc); g_free(content); g_free(tools); + return; + } + + char *d_tag = agent_skills_publish(name, desc, content, tools); + g_free(name); g_free(desc); g_free(content); g_free(tools); + + if (d_tag == NULL) { + respond_error_json(request, -1, "Failed to publish skill"); + return; + } + + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "published"); + cJSON_AddStringToObject(result, "d", d_tag); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); + g_free(d_tag); +} + +/* Handle sovereign://agents/skills/delete?d=... — delete a skill. */ +static void handle_agents_skills_delete(WebKitURISchemeRequest *request, + const char *query) { + char *d = query_param(query, "d"); + if (d == NULL || d[0] == '\0') { + respond_error_json(request, -1, "Missing d parameter"); + g_free(d); + return; + } + + int rc = agent_skills_delete(d); + g_free(d); + + if (rc != 0) { + respond_error_json(request, -1, "Failed to delete skill"); + return; + } + + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "deleted"); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); +} + +/* Handle sovereign://agents/skills/default — return the Sovereign + * Browser Skill from local settings (not from Nostr). This is the + * "unsaved" default skill shown at the top of the chat skills list. */ +static void handle_agents_skills_default(WebKitURISchemeRequest *request) { + cJSON *def = agent_skills_get_default(); + if (def == NULL) { + respond_json(request, "{}"); + return; + } + char *json = cJSON_PrintUnformatted(def); + cJSON_Delete(def); + respond_json(request, json); + free(json); +} + +/* Handle sovereign://agents/skills/update?d=...&name=...&description=... + * &content=...&requires_tools=... — re-publish an existing skill with + * edited content. The d-tag is preserved (addressable event). */ +static void handle_agents_skills_update(WebKitURISchemeRequest *request, + const char *query) { + char *d = query_param(query, "d"); + char *name = query_param(query, "name"); + char *desc = query_param(query, "description"); + char *content = query_param(query, "content"); + char *tools = query_param(query, "requires_tools"); + + if (d == NULL || d[0] == '\0') { + respond_error_json(request, -1, "Missing d parameter"); + g_free(d); g_free(name); g_free(desc); + g_free(content); g_free(tools); + return; + } + if (content == NULL || content[0] == '\0') { + respond_error_json(request, -1, "Missing content parameter"); + g_free(d); g_free(name); g_free(desc); + g_free(content); g_free(tools); + return; + } + + int rc = agent_skills_update(d, name, desc, content, tools); + g_free(d); g_free(name); g_free(desc); + g_free(content); g_free(tools); + + if (rc != 0) { + respond_error_json(request, -1, "Failed to update skill"); + return; + } + + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "updated"); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); +} + +/* ── JSON data endpoints for embedded pages ────────────────────── */ + +/* Handle sovereign://agents/config — return the current agent config as JSON. + * Used by www/agents/config.js to populate the config page at runtime. */ +static void handle_agents_config_json(WebKitURISchemeRequest *request) { + const browser_settings_t *bs = settings_get(); + + const agent_provider_t *ap = + (bs->agent_active_provider >= 0 && + bs->agent_active_provider < bs->agent_provider_count) + ? &bs->agent_providers[bs->agent_active_provider] + : NULL; + + cJSON *root = cJSON_CreateObject(); + + /* Provider list (names only). */ + cJSON *providers = cJSON_CreateArray(); + for (int i = 0; i < bs->agent_provider_count; i++) { + cJSON_AddItemToArray(providers, + cJSON_CreateString(bs->agent_providers[i].name)); + } + cJSON_AddItemToObject(root, "providers", providers); + cJSON_AddNumberToObject(root, "active_provider", + bs->agent_active_provider); + cJSON_AddStringToObject(root, "provider_name", + ap ? ap->name : ""); + + /* Active provider resolved fields. */ + cJSON_AddStringToObject(root, "base_url", + ap ? ap->base_url : bs->agent_llm_base_url); + cJSON_AddStringToObject(root, "api_key", + ap ? ap->api_key : bs->agent_llm_api_key); + + /* Model list from the active provider. */ + cJSON *models = cJSON_CreateArray(); + if (ap) { + for (int m = 0; m < ap->model_count; m++) { + cJSON_AddItemToArray(models, + cJSON_CreateString(ap->models[m])); + } + } + cJSON_AddItemToObject(root, "models", models); + cJSON_AddStringToObject(root, "model", bs->agent_llm_model); + + cJSON_AddNumberToObject(root, "max_iterations", + bs->agent_max_iterations); + cJSON_AddStringToObject(root, "system_prompt", + bs->agent_skill_template[0] ? bs->agent_skill_template + : SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT); + + /* Sovereign Browser Skill fields. */ + cJSON_AddStringToObject(root, "skill_name", + bs->agent_skill_name[0] ? bs->agent_skill_name + : SETTINGS_AGENT_SKILL_NAME_DEFAULT); + cJSON_AddStringToObject(root, "skill_description", + bs->agent_skill_description[0] ? bs->agent_skill_description + : SETTINGS_AGENT_SKILL_DESCRIPTION_DEFAULT); + cJSON_AddStringToObject(root, "skill_template", + bs->agent_skill_template[0] ? bs->agent_skill_template + : SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT); + cJSON_AddStringToObject(root, "skill_requires_tools", + bs->agent_skill_requires_tools[0] ? bs->agent_skill_requires_tools + : SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_DEFAULT); + + char *json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + respond_json(request, json); + free(json); +} + +/* Handle sovereign://settings/config — return all settings as JSON. + * Used by www/settings.js to populate the settings page at runtime. */ +static void handle_settings_config_json(WebKitURISchemeRequest *request) { + const browser_settings_t *bs = settings_get(); + + /* Read current WebKit security settings state. */ + int dev_extras = g_bridge.settings ? + webkit_settings_get_enable_developer_extras(g_bridge.settings) : 0; + int file_access = g_bridge.settings ? + webkit_settings_get_allow_file_access_from_file_urls(g_bridge.settings) : 0; + int universal_access = g_bridge.settings ? + webkit_settings_get_allow_universal_access_from_file_urls(g_bridge.settings) : 0; + + const char *pos_str = "top"; + switch (bs->tab_bar_position) { + case GTK_POS_TOP: pos_str = "top"; break; + case GTK_POS_BOTTOM: pos_str = "bottom"; break; + case GTK_POS_LEFT: pos_str = "left"; break; + case GTK_POS_RIGHT: pos_str = "right"; break; + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "theme_dark", bs->theme_dark); + cJSON_AddBoolToObject(root, "restore_session", bs->restore_session); + cJSON_AddStringToObject(root, "new_tab_url", bs->new_tab_url); + cJSON_AddStringToObject(root, "tab_bar_position", pos_str); + cJSON_AddBoolToObject(root, "show_tab_close_buttons", + bs->show_tab_close_buttons); + cJSON_AddBoolToObject(root, "middle_click_close", + bs->middle_click_close); + cJSON_AddBoolToObject(root, "ctrl_tab_switch", bs->ctrl_tab_switch); + cJSON_AddBoolToObject(root, "tab_drag_reorder", bs->tab_drag_reorder); + cJSON_AddNumberToObject(root, "max_tabs", bs->max_tabs); + cJSON_AddBoolToObject(root, "agent_server_enabled", + bs->agent_server_enabled); + cJSON_AddNumberToObject(root, "agent_server_port", + bs->agent_server_port); + cJSON_AddStringToObject(root, "agent_allowed_origins", + bs->agent_allowed_origins); + cJSON_AddNumberToObject(root, "agent_login_timeout_ms", + bs->agent_login_timeout_ms); + cJSON_AddStringToObject(root, "bootstrap_relays", + bs->bootstrap_relays); + cJSON_AddStringToObject(root, "search_engine", bs->search_engine); + cJSON_AddBoolToObject(root, "dev_extras", dev_extras); + cJSON_AddBoolToObject(root, "file_access", file_access); + cJSON_AddBoolToObject(root, "universal_access", universal_access); + + /* Search engine list. */ + cJSON *engines_arr = cJSON_CreateArray(); + const search_engine_t *engines = search_engines_get(); + for (int i = 0; engines[i].id != NULL; i++) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddStringToObject(e, "id", engines[i].id); + cJSON_AddStringToObject(e, "name", engines[i].name); + cJSON_AddItemToArray(engines_arr, e); + } + cJSON_AddItemToObject(root, "search_engines", engines_arr); + + /* Keyboard shortcuts. */ + cJSON *shortcuts_arr = cJSON_CreateArray(); + for (int i = 0; i < SHORTCUT_COUNT; i++) { + const shortcut_meta_t *m = shortcuts_meta((shortcut_action_t)i); + const char *accel = shortcuts_get((shortcut_action_t)i); + cJSON *sc = cJSON_CreateObject(); + cJSON_AddStringToObject(sc, "id", m->id); + cJSON_AddStringToObject(sc, "label", m->label); + cJSON_AddStringToObject(sc, "accel", accel ? accel : ""); + cJSON_AddItemToArray(shortcuts_arr, sc); + } + cJSON_AddItemToObject(root, "shortcuts", shortcuts_arr); + + char *json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + respond_json(request, json); + free(json); +} + +/* Handle sovereign://bookmarks/list — return all bookmark directories + * and their bookmarks as JSON. Used by www/bookmarks.js. */ +static void handle_bookmarks_list_json(WebKitURISchemeRequest *request) { + int dir_count = 0; + const bookmark_dir_t *dirs = bookmarks_get_dirs(&dir_count); + int have_signer = (g_bridge.signer != NULL && !g_bridge.readonly); + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "have_signer", have_signer); + + cJSON *dirs_arr = cJSON_CreateArray(); + for (int i = 0; i < dir_count; i++) { + const bookmark_dir_t *dir = &dirs[i]; + cJSON *dobj = cJSON_CreateObject(); + cJSON_AddStringToObject(dobj, "name", dir->name); + cJSON_AddNumberToObject(dobj, "count", dir->count); + cJSON *bms = cJSON_CreateArray(); + for (int j = 0; j < dir->count; j++) { + const bookmark_t *bm = &dir->bookmarks[j]; + cJSON *bobj = cJSON_CreateObject(); + cJSON_AddStringToObject(bobj, "url", bm->url); + cJSON_AddStringToObject(bobj, "title", bm->title); + cJSON_AddNumberToObject(bobj, "added", (double)bm->added); + cJSON_AddItemToArray(bms, bobj); + } + cJSON_AddItemToObject(dobj, "bookmarks", bms); + cJSON_AddItemToArray(dirs_arr, dobj); + } + cJSON_AddItemToObject(root, "dirs", dirs_arr); + + char *json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + respond_json(request, json); + free(json); +} + +/* Handle sovereign://profile/data — return the user's Nostr profile data + * as JSON. Used by www/profile.js. */ +static void handle_profile_data_json(WebKitURISchemeRequest *request) { + cJSON *root = cJSON_CreateObject(); + + if (g_bridge.pubkey_hex[0] == '\0') { + /* No identity loaded — return empty object; JS shows the + * "no identity" message when pubkey is absent. */ + char *json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + respond_json(request, json); + free(json); + return; + } + + /* Truncated pubkey for display. */ + char pk_short[20]; + snprintf(pk_short, sizeof(pk_short), "%.16s", g_bridge.pubkey_hex); + cJSON_AddStringToObject(root, "pubkey", g_bridge.pubkey_hex); + cJSON_AddStringToObject(root, "pubkey_short", pk_short); + cJSON_AddStringToObject(root, "method", + g_bridge.readonly ? "read-only" : "signing"); + cJSON_AddStringToObject(root, "signing", + g_bridge.signer ? "active" : + (g_bridge.readonly ? "no signer" : "none")); + + /* Fetch kind 0 (profile metadata) and kind 3 (contacts). */ + cJSON *kind0 = db_get_latest_event(g_bridge.pubkey_hex, 0); + cJSON *kind3 = db_get_latest_event(g_bridge.pubkey_hex, 3); + + const char *name = "", *about = "", *picture = "", *display_name = ""; + const char *website = "", *lud16 = ""; + cJSON *meta = NULL; + if (kind0) { + cJSON *content = cJSON_GetObjectItemCaseSensitive(kind0, "content"); + if (cJSON_IsString(content) && content->valuestring[0]) { + meta = cJSON_Parse(content->valuestring); + if (meta) { + cJSON *item; + item = cJSON_GetObjectItemCaseSensitive(meta, "name"); + if (cJSON_IsString(item)) name = item->valuestring; + item = cJSON_GetObjectItemCaseSensitive(meta, "display_name"); + if (cJSON_IsString(item)) display_name = item->valuestring; + item = cJSON_GetObjectItemCaseSensitive(meta, "about"); + if (cJSON_IsString(item)) about = item->valuestring; + item = cJSON_GetObjectItemCaseSensitive(meta, "picture"); + if (cJSON_IsString(item)) picture = item->valuestring; + item = cJSON_GetObjectItemCaseSensitive(meta, "website"); + if (cJSON_IsString(item)) website = item->valuestring; + item = cJSON_GetObjectItemCaseSensitive(meta, "lud16"); + if (cJSON_IsString(item)) lud16 = item->valuestring; + } + } + } + cJSON_AddStringToObject(root, "name", name); + cJSON_AddStringToObject(root, "display_name", display_name); + cJSON_AddStringToObject(root, "about", about); + cJSON_AddStringToObject(root, "picture", picture); + cJSON_AddStringToObject(root, "website", website); + cJSON_AddStringToObject(root, "lud16", lud16); + cJSON_AddBoolToObject(root, "kind0_present", kind0 != NULL); + + if (kind0) { + cJSON *ca = cJSON_GetObjectItemCaseSensitive(kind0, "created_at"); + if (cJSON_IsNumber(ca)) { + char ca_buf[32]; + snprintf(ca_buf, sizeof(ca_buf), "%ld", + (long)ca->valuedouble); + cJSON_AddStringToObject(root, "kind0_created", ca_buf); + } else { + cJSON_AddStringToObject(root, "kind0_created", ""); + } + } else { + cJSON_AddStringToObject(root, "kind0_created", ""); + } + + /* Kind 3 contacts. */ + int contact_count = 0; + if (kind3) { + cJSON *tags = cJSON_GetObjectItemCaseSensitive(kind3, "tags"); + if (cJSON_IsArray(tags)) { + cJSON *tag; + cJSON_ArrayForEach(tag, tags) { + if (!cJSON_IsArray(tag)) continue; + cJSON *t0 = cJSON_GetArrayItem(tag, 0); + if (cJSON_IsString(t0) && + strcmp(t0->valuestring, "p") == 0) { + contact_count++; + } + } + } + cJSON *ca = cJSON_GetObjectItemCaseSensitive(kind3, "created_at"); + if (cJSON_IsNumber(ca)) { + char ca3_buf[32]; + snprintf(ca3_buf, sizeof(ca3_buf), "%ld", + (long)ca->valuedouble); + cJSON_AddStringToObject(root, "kind3_created", ca3_buf); + } else { + cJSON_AddStringToObject(root, "kind3_created", ""); + } + } else { + cJSON_AddStringToObject(root, "kind3_created", ""); + } + cJSON_AddBoolToObject(root, "kind3_present", kind3 != NULL); + cJSON_AddNumberToObject(root, "contact_count", contact_count); + + /* Event counts. */ + cJSON_AddNumberToObject(root, "kind0_count", + db_count_events(g_bridge.pubkey_hex, 0)); + cJSON_AddNumberToObject(root, "kind3_count", + db_count_events(g_bridge.pubkey_hex, 3)); + cJSON_AddNumberToObject(root, "kind10002_count", + db_count_events(g_bridge.pubkey_hex, 10002)); + + /* npub URI + QR code URL. */ + char npub_uri[160] = ""; + unsigned char pubkey_bytes[32] = {0}; + int valid = 1; + for (int i = 0; i < 32; i++) { + unsigned int byte; + if (sscanf(g_bridge.pubkey_hex + i * 2, "%02x", &byte) != 1) { + valid = 0; break; + } + pubkey_bytes[i] = (unsigned char)byte; + } + if (valid && nostr_build_uri_npub(pubkey_bytes, npub_uri, + sizeof(npub_uri)) == 0) { + cJSON_AddStringToObject(root, "npub", npub_uri); + char *enc = g_uri_escape_string(npub_uri, NULL, FALSE); + char qr_url[256]; + snprintf(qr_url, sizeof(qr_url), + "sovereign://qr?text=%s&box_size=6&border=4", enc); + cJSON_AddStringToObject(root, "qr_url", qr_url); + g_free(enc); + } else { + cJSON_AddStringToObject(root, "npub", ""); + cJSON_AddStringToObject(root, "qr_url", ""); + } + + char *json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (meta) cJSON_Delete(meta); + if (kind0) cJSON_Delete(kind0); + if (kind3) cJSON_Delete(kind3); + respond_json(request, json); + free(json); +} + /* ── URI scheme callback ────────────────────────────────────────── */ static void on_sovereign_scheme(WebKitURISchemeRequest *request, @@ -1725,7 +3234,13 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request, (void)user_data; const gchar *uri = webkit_uri_scheme_request_get_uri(request); - g_print("[bridge] %s\n", uri); + /* Suppress the log for high-frequency polling endpoints and the + * nostr bridge shim calls to avoid terminal spam. */ + if (strncmp(uri, "sovereign://agents/status", 25) != 0 && + strncmp(uri, "sovereign://agents/messages", 27) != 0 && + strncmp(uri, "sovereign://nostr/", 18) != 0) { + g_print("[bridge] %s\n", uri); + } if (uri == NULL) { respond_error_json(request, -1, "Invalid URI"); @@ -1769,15 +3284,49 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request, } /* Route sovereign://profile — show the user's Nostr profile from - * cached kind 0/3 events in the SQLite database. */ - if (strcmp(uri, "sovereign://profile") == 0) { - handle_profile_page(request); + * cached kind 0/3 events in the SQLite database. Served from the + * embedded file www/profile.html (Phase 3 migration). */ + if (strcmp(uri, "sovereign://profile") == 0 || + strncmp(uri, "sovereign://profile?", 20) == 0) { + serve_embedded_file(request, "profile.html"); + return; + } + if (strcmp(uri, "sovereign://profile.css") == 0 || + strncmp(uri, "sovereign://profile.css?", 24) == 0) { + serve_embedded_file(request, "profile.css"); + return; + } + if (strcmp(uri, "sovereign://profile.js") == 0 || + strncmp(uri, "sovereign://profile.js?", 23) == 0) { + serve_embedded_file(request, "profile.js"); + return; + } + if (strcmp(uri, "sovereign://profile/data") == 0 || + strncmp(uri, "sovereign://profile/data?", 25) == 0) { + handle_profile_data_json(request); return; } - /* Route sovereign://bookmarks — manage bookmarks and directories. */ - if (strcmp(uri, "sovereign://bookmarks") == 0) { - handle_bookmarks_page(request); + /* Route sovereign://bookmarks — manage bookmarks and directories. + * Served from the embedded file www/bookmarks.html (Phase 3). */ + if (strcmp(uri, "sovereign://bookmarks") == 0 || + strncmp(uri, "sovereign://bookmarks?", 22) == 0) { + serve_embedded_file(request, "bookmarks.html"); + return; + } + if (strcmp(uri, "sovereign://bookmarks.css") == 0 || + strncmp(uri, "sovereign://bookmarks.css?", 26) == 0) { + serve_embedded_file(request, "bookmarks.css"); + return; + } + if (strcmp(uri, "sovereign://bookmarks.js") == 0 || + strncmp(uri, "sovereign://bookmarks.js?", 25) == 0) { + serve_embedded_file(request, "bookmarks.js"); + return; + } + if (strcmp(uri, "sovereign://bookmarks/list") == 0 || + strncmp(uri, "sovereign://bookmarks/list?", 27) == 0) { + handle_bookmarks_list_json(request); return; } if (strncmp(uri, "sovereign://bookmarks/add?", 25) == 0) { @@ -1797,10 +3346,26 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request, return; } - /* Route sovereign://settings requests. */ + /* Route sovereign://settings requests. The page is served from the + * embedded file www/settings.html (Phase 3 migration). */ if (strcmp(uri, "sovereign://settings") == 0 || strncmp(uri, "sovereign://settings?", 21) == 0) { - handle_settings_page(request); + serve_embedded_file(request, "settings.html"); + return; + } + if (strcmp(uri, "sovereign://settings.css") == 0 || + strncmp(uri, "sovereign://settings.css?", 25) == 0) { + serve_embedded_file(request, "settings.css"); + return; + } + if (strcmp(uri, "sovereign://settings.js") == 0 || + strncmp(uri, "sovereign://settings.js?", 24) == 0) { + serve_embedded_file(request, "settings.js"); + return; + } + if (strcmp(uri, "sovereign://settings/config") == 0 || + strncmp(uri, "sovereign://settings/config?", 28) == 0) { + handle_settings_config_json(request); return; } if (strncmp(uri, "sovereign://settings/set", 24) == 0) { @@ -1819,6 +3384,217 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request, return; } + + /* Shared base CSS for all sovereign:// pages (theme variables, + * buttons, toggles, etc.). Linked via @import from page CSS files. */ + if (strcmp(uri, "sovereign://sovereign-base.css") == 0 || + strncmp(uri, "sovereign://sovereign-base.css?", 31) == 0) { + serve_embedded_file(request, "sovereign-base.css"); + return; + } + + /* Vendor JS libraries (embedded). Currently: marked.min.js for + * full markdown rendering in the chat page, and purify.min.js + * (DOMPurify) for sanitizing marked.js output. */ + if (strcmp(uri, "sovereign://vendor/marked.min.js") == 0 || + strncmp(uri, "sovereign://vendor/marked.min.js?", 33) == 0) { + serve_embedded_file(request, "vendor/marked.min.js"); + return; + } + if (strcmp(uri, "sovereign://vendor/purify.min.js") == 0 || + strncmp(uri, "sovereign://vendor/purify.min.js?", 33) == 0) { + serve_embedded_file(request, "vendor/purify.min.js"); + return; + } + + /* Shared CSS libraries (embedded) — used by the chat page and + * other sovereign:// pages. Copied from the client to keep the + * UI consistent across apps. */ + if (strcmp(uri, "sovereign://css/dot-menu.css") == 0 || + strncmp(uri, "sovereign://css/dot-menu.css?", 28) == 0) { + serve_embedded_file(request, "css/dot-menu.css"); + return; + } + if (strcmp(uri, "sovereign://css/post-composer.css") == 0 || + strncmp(uri, "sovereign://css/post-composer.css?", 33) == 0) { + serve_embedded_file(request, "css/post-composer.css"); + return; + } + + /* Shared JS libraries (embedded). These are plain scripts (not ES + * modules) that attach their exports to window, because WebKit's + * custom sovereign:// scheme does not support ES module imports. + * The .mjs originals are also served for completeness. */ + if (strcmp(uri, "sovereign://js/dot-menu.js") == 0 || + strncmp(uri, "sovereign://js/dot-menu.js?", 27) == 0) { + serve_embedded_file(request, "js/dot-menu.js"); + return; + } + if (strcmp(uri, "sovereign://js/post-composer.js") == 0 || + strncmp(uri, "sovereign://js/post-composer.js?", 30) == 0) { + serve_embedded_file(request, "js/post-composer.js"); + return; + } + if (strcmp(uri, "sovereign://js/dot-menu.mjs") == 0 || + strncmp(uri, "sovereign://js/dot-menu.mjs?", 28) == 0) { + serve_embedded_file(request, "js/dot-menu.mjs"); + return; + } + if (strcmp(uri, "sovereign://js/post-composer.mjs") == 0 || + strncmp(uri, "sovereign://js/post-composer.mjs?", 31) == 0) { + serve_embedded_file(request, "js/post-composer.mjs"); + return; + } + + /* Route sovereign://agents requests. The config page is served from + * the embedded file www/agents/config.html (Phase 3 migration). + * Placed before the nostr catch-all. Order matters: the JSON + * "config" endpoint and the .css/.js asset routes must be checked + * before the bare "agents" route (they're more specific). */ + if (strcmp(uri, "sovereign://agents/config.css") == 0 || + strncmp(uri, "sovereign://agents/config.css?", 30) == 0) { + serve_embedded_file(request, "agents/config.css"); + return; + } + if (strcmp(uri, "sovereign://agents/config.js") == 0 || + strncmp(uri, "sovereign://agents/config.js?", 29) == 0) { + serve_embedded_file(request, "agents/config.js"); + return; + } + if (strcmp(uri, "sovereign://agents/config") == 0 || + strncmp(uri, "sovereign://agents/config?", 26) == 0) { + handle_agents_config_json(request); + return; + } + if (strcmp(uri, "sovereign://agents") == 0 || + strncmp(uri, "sovereign://agents?", 19) == 0) { + serve_embedded_file(request, "agents/config.html"); + return; + } + if (strncmp(uri, "sovereign://agents/models", 25) == 0) { + const char *q = strchr(uri + 25, '?'); + handle_agents_models(request, q ? q + 1 : ""); + return; + } + if (strncmp(uri, "sovereign://agents/set", 22) == 0) { + const char *q = strchr(uri + 22, '?'); + handle_agents_set(request, q ? q + 1 : ""); + return; + } + /* Chat page assets — check the more-specific .css/.js routes before + * the bare "chat" route to avoid prefix collision. The HTML/CSS/JS + * are embedded at build time by embed_web_files.sh (see + * www/agents/chat.{html,css,js}). */ + if (strcmp(uri, "sovereign://agents/chat.css") == 0 || + strncmp(uri, "sovereign://agents/chat.css?", 28) == 0) { + serve_embedded_file(request, "agents/chat.css"); + return; + } + if (strcmp(uri, "sovereign://agents/chat.js") == 0 || + strncmp(uri, "sovereign://agents/chat.js?", 27) == 0) { + serve_embedded_file(request, "agents/chat.js"); + return; + } + if (strcmp(uri, "sovereign://agents/chat") == 0 || + strncmp(uri, "sovereign://agents/chat?", 24) == 0) { + serve_embedded_file(request, "agents/chat.html"); + return; + } + if (strcmp(uri, "sovereign://agents/messages") == 0 || + strncmp(uri, "sovereign://agents/messages?", 27) == 0) { + handle_agents_messages(request); + return; + } + if (strncmp(uri, "sovereign://agents/send", 23) == 0) { + const char *q = strchr(uri + 23, '?'); + handle_agents_send(request, q ? q + 1 : ""); + return; + } + if (strcmp(uri, "sovereign://agents/status") == 0 || + strncmp(uri, "sovereign://agents/status?", 26) == 0) { + handle_agents_status(request); + return; + } + if (strcmp(uri, "sovereign://agents/cancel") == 0 || + strncmp(uri, "sovereign://agents/cancel?", 26) == 0) { + handle_agents_cancel(request); + return; + } + + /* Skills endpoints (kind 31123). Order matters: more specific + * paths (selected, select, create, delete) must be checked before + * the bare "skills" list endpoint. */ + if (strcmp(uri, "sovereign://agents/skills/selected") == 0 || + strncmp(uri, "sovereign://agents/skills/selected?", 35) == 0) { + handle_agents_skills_selected(request); + return; + } + if (strncmp(uri, "sovereign://agents/skills/select?", 33) == 0) { + const char *q = uri + 33; + handle_agents_skills_select(request, q); + return; + } + if (strncmp(uri, "sovereign://agents/skills/create?", 33) == 0) { + const char *q = uri + 33; + handle_agents_skills_create(request, q); + return; + } + if (strncmp(uri, "sovereign://agents/skills/delete?", 33) == 0) { + const char *q = uri + 33; + handle_agents_skills_delete(request, q); + return; + } + if (strncmp(uri, "sovereign://agents/skills/update?", 33) == 0) { + const char *q = uri + 33; + handle_agents_skills_update(request, q); + return; + } + if (strcmp(uri, "sovereign://agents/skills/default") == 0 || + strncmp(uri, "sovereign://agents/skills/default?", 34) == 0) { + handle_agents_skills_default(request); + return; + } + if (strcmp(uri, "sovereign://agents/skills") == 0 || + strncmp(uri, "sovereign://agents/skills?", 26) == 0) { + handle_agents_skills(request); + return; + } + + /* Conversation persistence endpoints. Order matters: more specific + * paths (new, load, delete, save) must be checked before the bare + * "conversations" list endpoint. */ + if (strcmp(uri, "sovereign://agents/conversations/new") == 0 || + strncmp(uri, "sovereign://agents/conversations/new?", 37) == 0) { + const char *q = (uri[36] == '?') ? uri + 37 : NULL; + handle_agents_conversations_new(request, q); + return; + } + if (strncmp(uri, "sovereign://agents/conversations/load?", 38) == 0) { + const char *q = uri + 38; + handle_agents_conversations_load(request, q); + return; + } + if (strncmp(uri, "sovereign://agents/conversations/delete?", 40) == 0) { + const char *q = uri + 40; + handle_agents_conversations_delete(request, q); + return; + } + if (strncmp(uri, "sovereign://agents/conversations/save?", 38) == 0) { + const char *q = uri + 38; + handle_agents_conversations_save(request, q); + return; + } + if (strncmp(uri, "sovereign://agents/conversations/rename?", 40) == 0) { + const char *q = uri + 40; + handle_agents_conversations_rename(request, q); + return; + } + if (strcmp(uri, "sovereign://agents/conversations") == 0 || + strncmp(uri, "sovereign://agents/conversations?", 33) == 0) { + handle_agents_conversations(request); + return; + } + /* Route sovereign://nostr/ requests. */ if (strncmp(uri, "sovereign://nostr/", 18) != 0) { respond_error_json(request, -1, "Unknown sovereign:// path"); diff --git a/src/relay_fetch.c b/src/relay_fetch.c index 69ea8e7..68f57f8 100644 --- a/src/relay_fetch.c +++ b/src/relay_fetch.c @@ -47,7 +47,7 @@ int relay_fetch_bootstrap(const char *pubkey_hex, return -1; } - g_print("[relay] Fetching kind 0/3/10002/30003/30078 for %s from %d relay(s)...\n", + g_print("[relay] Fetching kind 0/3/10002/30003/30078/31123 for %s from %d relay(s)...\n", pubkey_hex, relay_count); /* Build the filter: {"authors": [pubkey], "kinds": [0, 3, 10002, 30003, 30078]} */ @@ -87,7 +87,8 @@ int relay_fetch_bootstrap(const char *pubkey_hex, /* Store each event in the database. Kind 30003 (bookmark sets) are * also decrypted and loaded into the in-memory bookmarks list. * Kind 30078 (NIP-78 app data) is stored and merged into local - * settings if it's our sovereign_browser settings event. */ + * settings if it's the shared d:user-settings event (or the legacy + * d:sovereign_browser event, which is migrated on merge). */ int stored = 0; for (int i = 0; i < result_count; i++) { if (results[i] == NULL) continue; @@ -104,7 +105,8 @@ int relay_fetch_bootstrap(const char *pubkey_hex, } else if (kind == 30078) { /* Store in SQLite first, then try to merge into local settings. * settings_sync_merge_from_nostr checks the d-tag and only - * merges if it's our "sovereign_browser" event. */ + * merges if it's the shared "user-settings" event (or the + * legacy "sovereign_browser" event, which is migrated). */ if (db_store_event(results[i]) == 0) { stored++; } @@ -132,6 +134,58 @@ int relay_fetch_bootstrap(const char *pubkey_hex, return stored; } +/* Fetch kind 31123 skill events from relays. Skills are PUBLIC events + * authored by ANY user (not just the logged-in user), so this uses a + * separate query without an authors filter. The events are stored in + * the SQLite cache for agent_skills_fetch() to read. + * + * Returns the number of events stored, or 0 on error. */ +static int relay_fetch_skills(const char **relay_urls, int relay_count) { + if (relay_urls == NULL || relay_count <= 0) return 0; + + g_print("[relay] Fetching kind 31123 skills from %d relay(s)...\n", + relay_count); + + /* Build the filter: {"kinds": [31123], "limit": 500} — no authors + * filter since skills are public and from any author. */ + cJSON *filter = cJSON_CreateObject(); + cJSON *kinds = cJSON_CreateArray(); + cJSON_AddItemToArray(kinds, cJSON_CreateNumber(31123)); + cJSON_AddItemToObject(filter, "kinds", kinds); + cJSON_AddNumberToObject(filter, "limit", 500); + + int result_count = 0; + cJSON **results = synchronous_query_relays_with_progress( + relay_urls, relay_count, filter, + RELAY_QUERY_ALL_RESULTS, &result_count, + RELAY_TIMEOUT_SECONDS, + NULL, NULL, + 0, NULL + ); + + cJSON_Delete(filter); + + if (results == NULL || result_count <= 0) { + g_print("[relay] No skill events found\n"); + if (results) free(results); + return 0; + } + + int stored = 0; + for (int i = 0; i < result_count; i++) { + if (results[i] == NULL) continue; + if (db_store_event(results[i]) == 0) { + stored++; + } + cJSON_Delete(results[i]); + } + free(results); + + g_print("[relay] Fetched %d skill event(s), stored %d\n", + result_count, stored); + return stored; +} + /* Idle callback wrapper to refresh the avatar on the main thread. */ static gboolean avatar_refresh_idle(gpointer data) { char *pubkey = (char *)data; @@ -178,6 +232,9 @@ gpointer relay_fetch_thread(gpointer data) { g_print("[relay] Background fetch started (%d relays)\n", relay_count); relay_fetch_bootstrap(pubkey_hex, relay_urls, relay_count); + /* Fetch public kind 31123 skill events (from any author). */ + relay_fetch_skills(relay_urls, relay_count); + g_free(relay_buf); g_free(pubkey_hex); return NULL; diff --git a/src/settings.c b/src/settings.c index 49b8ba2..f97b59f 100644 --- a/src/settings.c +++ b/src/settings.c @@ -71,11 +71,43 @@ static void settings_set_defaults(browser_settings_t *s) { SETTINGS_BOOTSTRAP_RELAYS_DEFAULT); snprintf(s->search_engine, sizeof(s->search_engine), "%s", SETTINGS_SEARCH_ENGINE_DEFAULT); - s->theme_dark = TRUE; /* default: dark mode */ + s->theme_dark = FALSE; /* default: light mode */ s->inspector_x = -1; /* -1 = let window manager decide */ s->inspector_y = -1; s->inspector_w = -1; s->inspector_h = -1; + /* Agent LLM provider settings */ + snprintf(s->agent_llm_base_url, sizeof(s->agent_llm_base_url), "%s", + SETTINGS_AGENT_LLM_BASE_URL_DEFAULT); + s->agent_llm_api_key[0] = '\0'; /* empty by default */ + snprintf(s->agent_llm_model, sizeof(s->agent_llm_model), "%s", + SETTINGS_AGENT_LLM_MODEL_DEFAULT); + s->agent_llm_system_prompt[0] = '\0'; /* legacy alias for skill_template */ + s->agent_max_iterations = SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT; + + /* Sovereign Browser Skill defaults. The template defaults to the + * built-in system prompt; the other fields describe the skill. */ + snprintf(s->agent_skill_name, + sizeof(s->agent_skill_name), "%s", + SETTINGS_AGENT_SKILL_NAME_DEFAULT); + snprintf(s->agent_skill_description, + sizeof(s->agent_skill_description), "%s", + SETTINGS_AGENT_SKILL_DESCRIPTION_DEFAULT); + snprintf(s->agent_skill_template, + sizeof(s->agent_skill_template), "%s", + SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT); + snprintf(s->agent_skill_requires_tools, + sizeof(s->agent_skill_requires_tools), "%s", + SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_DEFAULT); + + /* Multi-provider catalog — empty by default. Populated from the + * d:user-settings Nostr event (global.agent.providers) on login. + * If no providers are loaded, a single default provider is seeded + * from the legacy agent_llm_base_url / agent_llm_api_key fields. */ + s->agent_provider_count = 0; + s->agent_active_provider = -1; + s->agent_active_provider_name[0] = '\0'; + memset(s->agent_providers, 0, sizeof(s->agent_providers)); } /* ── Parsing helpers ──────────────────────────────────────────────── */ @@ -235,6 +267,82 @@ void settings_load_user(void) { val = db_kv_get("search_engine"); if (val) snprintf(g_settings.search_engine, sizeof(g_settings.search_engine), "%s", val); + + /* Agent LLM provider settings — these are the "resolved" values + * (active provider's base_url + api_key + selected model). They are + * populated from db_kv here for local persistence, and overwritten + * by settings_sync_merge_from_nostr() from global.agent.providers + + * sovereign_browser.agent when a Nostr event is available. */ + val = db_kv_get("agent.llm_base_url"); + if (val) snprintf(g_settings.agent_llm_base_url, sizeof(g_settings.agent_llm_base_url), "%s", val); + + val = db_kv_get("agent.llm_api_key"); + if (val) snprintf(g_settings.agent_llm_api_key, sizeof(g_settings.agent_llm_api_key), "%s", val); + + val = db_kv_get("agent.llm_model"); + if (val) snprintf(g_settings.agent_llm_model, sizeof(g_settings.agent_llm_model), "%s", val); + + val = db_kv_get("agent.llm_system_prompt"); + if (val) snprintf(g_settings.agent_llm_system_prompt, sizeof(g_settings.agent_llm_system_prompt), "%s", val); + + val = db_kv_get("agent.max_iterations"); + if (val) g_settings.agent_max_iterations = atoi(val); + + /* Sovereign Browser Skill fields. The template mirrors + * agent_llm_system_prompt for backward compatibility — if the + * legacy key is set but the new skill_template key is not, the + * template falls back to the legacy value (or the default). */ + val = db_kv_get("agent.skill_name"); + if (val) snprintf(g_settings.agent_skill_name, + sizeof(g_settings.agent_skill_name), "%s", val); + + val = db_kv_get("agent.skill_description"); + if (val) snprintf(g_settings.agent_skill_description, + sizeof(g_settings.agent_skill_description), "%s", val); + + val = db_kv_get("agent.skill_template"); + if (val && val[0]) { + snprintf(g_settings.agent_skill_template, + sizeof(g_settings.agent_skill_template), "%s", val); + } else if (g_settings.agent_llm_system_prompt[0]) { + /* Migrate from the legacy system_prompt field. */ + snprintf(g_settings.agent_skill_template, + sizeof(g_settings.agent_skill_template), "%s", + g_settings.agent_llm_system_prompt); + } + /* Keep the legacy alias in sync (truncates to 4096 safely). */ + if (g_settings.agent_skill_template[0]) { + g_strlcpy(g_settings.agent_llm_system_prompt, + g_settings.agent_skill_template, + sizeof(g_settings.agent_llm_system_prompt)); + } + + val = db_kv_get("agent.skill_requires_tools"); + if (val) snprintf(g_settings.agent_skill_requires_tools, + sizeof(g_settings.agent_skill_requires_tools), "%s", val); + + /* If no provider catalog was loaded from Nostr (agent_provider_count + * == 0), seed a single "default" provider from the resolved + * agent_llm_base_url / agent_llm_api_key so the agents config page + * has something to show. */ + if (g_settings.agent_provider_count == 0) { + agent_provider_t *p = &g_settings.agent_providers[0]; + memset(p, 0, sizeof(*p)); + snprintf(p->name, sizeof(p->name), "default"); + snprintf(p->base_url, sizeof(p->base_url), "%s", + g_settings.agent_llm_base_url); + snprintf(p->api_key, sizeof(p->api_key), "%s", + g_settings.agent_llm_api_key); + if (g_settings.agent_llm_model[0]) { + snprintf(p->models[0], sizeof(p->models[0]), "%s", + g_settings.agent_llm_model); + p->model_count = 1; + } + g_settings.agent_provider_count = 1; + g_settings.agent_active_provider = 0; + snprintf(g_settings.agent_active_provider_name, + sizeof(g_settings.agent_active_provider_name), "default"); + } } void settings_load(void) { @@ -309,6 +417,23 @@ void settings_save_user(void) { db_kv_set("bootstrap_relays", g_settings.bootstrap_relays); db_kv_set("search_engine", g_settings.search_engine); + + /* Agent LLM provider settings — resolved values (active provider). */ + db_kv_set("agent.llm_base_url", g_settings.agent_llm_base_url); + db_kv_set("agent.llm_api_key", g_settings.agent_llm_api_key); + db_kv_set("agent.llm_model", g_settings.agent_llm_model); + db_kv_set("agent.llm_system_prompt", g_settings.agent_skill_template); + db_kv_set("agent.provider", g_settings.agent_active_provider_name); + + snprintf(buf, sizeof(buf), "%d", g_settings.agent_max_iterations); + db_kv_set("agent.max_iterations", buf); + + /* Sovereign Browser Skill fields. */ + db_kv_set("agent.skill_name", g_settings.agent_skill_name); + db_kv_set("agent.skill_description", g_settings.agent_skill_description); + db_kv_set("agent.skill_template", g_settings.agent_skill_template); + db_kv_set("agent.skill_requires_tools", + g_settings.agent_skill_requires_tools); } void settings_save(void) { diff --git a/src/settings.h b/src/settings.h index b8eb445..5282661 100644 --- a/src/settings.h +++ b/src/settings.h @@ -28,6 +28,35 @@ extern "C" { "wss://relay.damus.io" #define SETTINGS_SEARCH_ENGINE_MAX 64 #define SETTINGS_SEARCH_ENGINE_DEFAULT "duckduckgo" +#define SETTINGS_AGENT_LLM_BASE_URL_DEFAULT "https://api.ppq.ai" +#define SETTINGS_AGENT_LLM_MODEL_DEFAULT "gpt-4o" +#define SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT 100 +#define SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT \ + "You are an AI assistant embedded in a web browser. You have access to browser automation tools (navigate, snapshot, click, fill, etc.) and system tools (filesystem read/write, shell command execution). Use the snapshot tool to understand page content, then interact with elements using refs (e.g. @e1). You can read and write files and run shell commands. Be concise in your responses. When a task is complete, summarize what you did." + +/* Sovereign Browser Skill — the default skill presented on the config + * page and chat page. The template is the system prompt; the other + * fields describe the skill for publishing as kind 31123. */ +#define SETTINGS_AGENT_SKILL_NAME_DEFAULT "Sovereign Browser Default" +#define SETTINGS_AGENT_SKILL_DESCRIPTION_DEFAULT "Default agent skill for sovereign_browser" +#define SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_DEFAULT "browser, fs, shell" +#define SETTINGS_AGENT_SKILL_TEMPLATE_MAX 8192 +#define SETTINGS_AGENT_SKILL_NAME_MAX 128 +#define SETTINGS_AGENT_SKILL_DESCRIPTION_MAX 512 +#define SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_MAX 256 + +#define SETTINGS_AGENT_MAX_PROVIDERS 16 +#define SETTINGS_AGENT_MAX_MODELS 64 +#define SETTINGS_AGENT_PROVIDER_NAME_MAX 64 + +/* A single LLM provider entry in the shared global.agent.providers catalog. */ +typedef struct { + char name[SETTINGS_AGENT_PROVIDER_NAME_MAX]; /* e.g. "ppq", "ollama-local" */ + char base_url[512]; + char api_key[512]; + char models[SETTINGS_AGENT_MAX_MODELS][128]; /* list of known model ids */ + int model_count; +} agent_provider_t; typedef struct { gboolean restore_session; /* restore open tabs on next launch */ @@ -49,6 +78,29 @@ typedef struct { int inspector_y; /* inspector detached window Y (-1 = default) */ int inspector_w; /* inspector detached window width (-1 = default) */ int inspector_h; /* inspector detached window height (-1 = default) */ + /* Agent LLM provider settings — "resolved" values for the active + * provider + selected model. Populated from global.agent.providers + * (base_url, api_key) and sovereign_browser.agent (model). */ + char agent_llm_base_url[512]; /* e.g. "https://api.openai.com/v1" */ + char agent_llm_api_key[512]; /* bearer token (may be empty for local servers) */ + char agent_llm_model[128]; /* e.g. "gpt-4o", "llama3.1" */ + char agent_llm_system_prompt[4096]; /* legacy alias for agent_skill_template (kept in sync) */ + int agent_max_iterations; /* max tool-call loop iterations (default 100) */ + + /* Sovereign Browser Skill — the default skill presented on the + * config page and chat page. The template is the system prompt + * (mirrored into agent_llm_system_prompt for backward compat). + * Published as kind 31123 when the user clicks "Save as Skill". */ + char agent_skill_name[SETTINGS_AGENT_SKILL_NAME_MAX]; + char agent_skill_description[SETTINGS_AGENT_SKILL_DESCRIPTION_MAX]; + char agent_skill_template[SETTINGS_AGENT_SKILL_TEMPLATE_MAX]; + char agent_skill_requires_tools[SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_MAX]; + + /* Multi-provider catalog (shared via global.agent in d:user-settings). */ + agent_provider_t agent_providers[SETTINGS_AGENT_MAX_PROVIDERS]; + int agent_provider_count; + int agent_active_provider; /* index into agent_providers, -1 if none */ + char agent_active_provider_name[SETTINGS_AGENT_PROVIDER_NAME_MAX]; } browser_settings_t; /* diff --git a/src/settings_sync.c b/src/settings_sync.c index 083f22b..92b77d8 100644 --- a/src/settings_sync.c +++ b/src/settings_sync.c @@ -2,15 +2,45 @@ * settings_sync.c — NIP-78 (kind 30078) settings sync for sovereign_browser * * Syncs whitelisted, device-independent settings + keyboard shortcut - * bindings across all devices the user logs in on. Uses a single kind - * 30078 addressable event with d-tag "sovereign_browser". The content is - * NIP-44 encrypted (self-to-self) JSON. + * bindings across all devices the user logs in on. Uses a single shared + * kind 30078 addressable event with d-tag "user-settings" (the same event + * used by ~/lt/client and ~/lt/didactyl). The content is NIP-44 encrypted + * (self-to-self) JSON with a "global" namespace (shared agent provider + * catalog under global.agent) and per-app namespaces (sovereign_browser, + * client, didactyl, ...). + * + * sovereign_browser writes only the "sovereign_browser" and "global.agent" + * namespaces. Other apps' namespaces are preserved via read-modify-write: + * on publish, the current d:user-settings event is fetched from the SQLite + * cache (or relays), decrypted, the sovereign_browser + global.agent + * namespaces are patched, and the result is re-encrypted and published. * * The publish path reuses the same pattern as bookmarks.c's * publish_directory(): serialize → NIP-44 encrypt → sign → store in * SQLite → publish to bootstrap relays. * * settings_sync_publish() is debounced (500ms) so rapid edits coalesce. + * + * Schema (v2): + * { + * "v": 2, + * "updatedAt": , + * "global": { + * "agent": { + * "providers": [ {name, base_url, api_key, models}, ... ], + * "favorites": [ "model-id", ... ] + * }, + * ... (zaps, ui, relays, ... — preserved, not touched by us) + * }, + * "sovereign_browser": { + * "agent": { provider, model, system_prompt, max_iterations }, + * "new_tab_url": "", "tab_bar_position": 0, "max_tabs": 50, + * "bootstrap_relays": "...", "search_engine": "duckduckgo", + * "theme_dark": false, "shortcuts": { ... } + * }, + * "client": { ... }, // preserved + * "didactyl": { ... } // preserved + * } */ #include "settings_sync.h" @@ -38,11 +68,12 @@ static guint g_publish_timeout_id = 0; /* db_kv key for the last-synced timestamp. */ #define SYNC_TS_KEY "settings_sync.nostr_synced_at" -/* ── Whitelist of syncable settings ─────────────────────────────────── */ +/* ── Whitelist of syncable browser settings ─────────────────────────── */ -/* Keys from the settings struct that should be synced. Device-specific - * settings (agent port, allowed origins, session restore, security - * toggles) are intentionally excluded. */ +/* Keys from the settings struct that should be synced under the + * sovereign_browser namespace. Device-specific settings (agent port, + * allowed origins, session restore, security toggles) are intentionally + * excluded. */ static const char *g_sync_setting_keys[] = { "new_tab_url", "tab_bar_position", @@ -111,33 +142,197 @@ static char *decrypt_content(const char *ciphertext) { return plaintext; } -/* ── Serialization ──────────────────────────────────────────────────── */ +/* Check whether a kind 30078 event has a given d-tag value. + * Returns 1 if matched, 0 otherwise. */ +static int event_has_d_tag(const cJSON *event, const char *d_value) { + cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags"); + if (!cJSON_IsArray(tags)) return 0; + cJSON *tag; + cJSON_ArrayForEach(tag, tags) { + if (!cJSON_IsArray(tag)) continue; + cJSON *t0 = cJSON_GetArrayItem(tag, 0); + cJSON *t1 = cJSON_GetArrayItem(tag, 1); + if (cJSON_IsString(t0) && strcmp(t0->valuestring, "d") == 0 && + cJSON_IsString(t1) && strcmp(t1->valuestring, d_value) == 0) { + return 1; + } + } + return 0; +} -/* Serialize all syncable settings + shortcuts to a JSON object: - * {"settings":{...}, "shortcuts":{...}} +/* ── Serialization: build sovereign_browser namespace ───────────────── */ + +/* Build the "sovereign_browser" namespace object from current settings. * Returns a newly allocated cJSON object (caller must delete). */ -static cJSON *serialize_payload(void) { - cJSON *root = cJSON_CreateObject(); - - /* Settings whitelist. */ - cJSON *settings_obj = cJSON_CreateObject(); - const browser_settings_t *s = settings_get(); - (void)s; /* read via db_kv_get to get string values uniformly */ +static cJSON *build_sovereign_browser_namespace(void) { + cJSON *sb = cJSON_CreateObject(); + /* Browser settings whitelist (read via db_kv_get for uniform string + * values — same approach as the original serialize_payload). */ for (int i = 0; i < g_sync_setting_count; i++) { const char *key = g_sync_setting_keys[i]; const char *val = db_kv_get(key); if (val) { - cJSON_AddStringToObject(settings_obj, key, val); + cJSON_AddStringToObject(sb, key, val); } } - cJSON_AddItemToObject(root, "settings", settings_obj); + + /* Per-app agent config: provider, model, system_prompt (legacy), + * max_iterations, and the Sovereign Browser Skill fields. These are + * stored in db_kv under the agent.* keys by handle_agents_set. */ + cJSON *agent = cJSON_CreateObject(); + const char *provider = db_kv_get("agent.provider"); + const char *model = db_kv_get("agent.llm_model"); + const char *prompt = db_kv_get("agent.llm_system_prompt"); + const char *maxit = db_kv_get("agent.max_iterations"); + const char *sk_name = db_kv_get("agent.skill_name"); + const char *sk_desc = db_kv_get("agent.skill_description"); + const char *sk_tmpl = db_kv_get("agent.skill_template"); + const char *sk_tools = db_kv_get("agent.skill_requires_tools"); + cJSON_AddStringToObject(agent, "provider", + provider ? provider : ""); + cJSON_AddStringToObject(agent, "model", + model ? model : ""); + cJSON_AddStringToObject(agent, "system_prompt", + prompt ? prompt : ""); + cJSON_AddNumberToObject(agent, "max_iterations", + maxit ? (double)atol(maxit) + : (double)SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT); + cJSON_AddStringToObject(agent, "skill_name", + sk_name ? sk_name : ""); + cJSON_AddStringToObject(agent, "skill_description", + sk_desc ? sk_desc : ""); + cJSON_AddStringToObject(agent, "skill_template", + sk_tmpl ? sk_tmpl : ""); + cJSON_AddStringToObject(agent, "skill_requires_tools", + sk_tools ? sk_tools : ""); + cJSON_AddItemToObject(sb, "agent", agent); /* Shortcuts. */ cJSON *shortcuts_obj = shortcuts_serialize(); - cJSON_AddItemToObject(root, "shortcuts", shortcuts_obj); + cJSON_AddItemToObject(sb, "shortcuts", shortcuts_obj); - return root; + return sb; +} + +/* Build the "global.agent" namespace object from the in-memory provider + * catalog. Returns a newly allocated cJSON object (caller must delete). */ +static cJSON *build_global_agent_namespace(void) { + cJSON *agent = cJSON_CreateObject(); + + const browser_settings_t *s = settings_get(); + + /* providers array */ + cJSON *providers = cJSON_CreateArray(); + for (int i = 0; i < s->agent_provider_count; i++) { + const agent_provider_t *p = &s->agent_providers[i]; + cJSON *pobj = cJSON_CreateObject(); + cJSON_AddStringToObject(pobj, "name", p->name); + cJSON_AddStringToObject(pobj, "base_url", p->base_url); + cJSON_AddStringToObject(pobj, "api_key", p->api_key); + cJSON *models = cJSON_CreateArray(); + for (int m = 0; m < p->model_count; m++) { + cJSON_AddItemToArray(models, cJSON_CreateString(p->models[m])); + } + cJSON_AddItemToObject(pobj, "models", models); + cJSON_AddItemToArray(providers, pobj); + } + cJSON_AddItemToObject(agent, "providers", providers); + + /* favorites — for now, just the currently selected model. */ + cJSON *favorites = cJSON_CreateArray(); + if (s->agent_llm_model[0]) { + cJSON_AddItemToArray(favorites, cJSON_CreateString(s->agent_llm_model)); + } + cJSON_AddItemToObject(agent, "favorites", favorites); + + return agent; +} + +/* ── Read-modify-write: fetch current event, patch, return payload ──── */ + +/* Fetch the current d:user-settings event from the SQLite cache. + * Returns a newly allocated cJSON event (caller must delete), or NULL. */ +static cJSON *fetch_current_user_settings_event(void) { + if (g_pubkey[0] == '\0') return NULL; + /* db_get_latest_event returns the newest event of a given kind for + * the pubkey. We then verify the d-tag is "user-settings". */ + cJSON *events = db_get_events(g_pubkey, SETTINGS_SYNC_KIND, 32); + if (events == NULL) return NULL; + int n = cJSON_GetArraySize(events); + cJSON *found = NULL; + for (int i = 0; i < n; i++) { + cJSON *ev = cJSON_GetArrayItem(events, i); + if (event_has_d_tag(ev, SETTINGS_SYNC_D_TAG)) { + found = cJSON_Duplicate(ev, 1); + break; + } + } + cJSON_Delete(events); + return found; +} + +/* Decrypt the content of an event and parse it as a JSON object. + * Returns a newly allocated cJSON object (caller must delete), or NULL. */ +static cJSON *decrypt_event_payload(const cJSON *event) { + cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content"); + if (!cJSON_IsString(content)) return NULL; + char *plaintext = decrypt_content(content->valuestring); + if (plaintext == NULL) return NULL; + cJSON *payload = cJSON_Parse(plaintext); + free(plaintext); + return payload; +} + +/* Build the full payload to publish, using read-modify-write: + * 1. Fetch the current d:user-settings event from SQLite cache. + * 2. Decrypt + parse it (or start a fresh v2 object if none). + * 3. Patch the "sovereign_browser" and "global.agent" namespaces. + * 4. Return the patched payload (caller must delete). + * + * Other namespaces (client, didactyl, global.zaps, global.ui, ...) are + * preserved untouched. */ +static cJSON *build_publish_payload(void) { + cJSON *payload = NULL; + + cJSON *current = fetch_current_user_settings_event(); + if (current != NULL) { + payload = decrypt_event_payload(current); + cJSON_Delete(current); + } + + if (payload == NULL || !cJSON_IsObject(payload)) { + /* No existing event (or decrypt failed) — start fresh. */ + if (payload) cJSON_Delete(payload); + payload = cJSON_CreateObject(); + cJSON_AddNumberToObject(payload, "v", 2); + } + + /* Ensure "global" object exists. */ + cJSON *global = cJSON_GetObjectItemCaseSensitive(payload, "global"); + if (!cJSON_IsObject(global)) { + global = cJSON_CreateObject(); + cJSON_AddItemToObject(payload, "global", global); + } + + /* Patch global.agent (replace entirely with our view). */ + cJSON *old_agent = cJSON_GetObjectItemCaseSensitive(global, "agent"); + if (old_agent) cJSON_DeleteItemFromObjectCaseSensitive(global, "agent"); + cJSON *new_agent = build_global_agent_namespace(); + cJSON_AddItemToObject(global, "agent", new_agent); + + /* Patch sovereign_browser namespace (replace entirely). */ + cJSON *old_sb = cJSON_GetObjectItemCaseSensitive(payload, "sovereign_browser"); + if (old_sb) cJSON_DeleteItemFromObjectCaseSensitive(payload, "sovereign_browser"); + cJSON *new_sb = build_sovereign_browser_namespace(); + cJSON_AddItemToObject(payload, "sovereign_browser", new_sb); + + /* Update top-level updatedAt. */ + cJSON *ts = cJSON_GetObjectItemCaseSensitive(payload, "updatedAt"); + if (ts) cJSON_DeleteItemFromObjectCaseSensitive(payload, "updatedAt"); + cJSON_AddNumberToObject(payload, "updatedAt", (double)time(NULL)); + + return payload; } /* ── Publish (debounced) ────────────────────────────────────────────── */ @@ -146,8 +341,13 @@ static cJSON *serialize_payload(void) { static void do_publish(void) { if (!g_have_signer) return; - /* Serialize. */ - cJSON *payload = serialize_payload(); + /* Build the payload via read-modify-write. */ + cJSON *payload = build_publish_payload(); + if (payload == NULL) { + g_printerr("[settings_sync] Failed to build payload\n"); + return; + } + char *json = cJSON_PrintUnformatted(payload); cJSON_Delete(payload); if (json == NULL) { @@ -160,7 +360,7 @@ static void do_publish(void) { free(json); if (ciphertext == NULL) return; - /* Build tags: [["d", "sovereign_browser"], ["client", "sovereign_browser"]] */ + /* Build tags: [["d", "user-settings"], ["client", "sovereign_browser"]] */ cJSON *tags = cJSON_CreateArray(); cJSON *d_tag = cJSON_CreateArray(); @@ -203,8 +403,9 @@ static void do_publish(void) { relay_urls, relay_count, event, &success_count, 15, NULL, NULL, 0, NULL); if (results) free(results); - g_print("[settings_sync] Published kind %d to %d/%d relays\n", - SETTINGS_SYNC_KIND, success_count, relay_count); + g_print("[settings_sync] Published kind %d (d:%s) to %d/%d relays\n", + SETTINGS_SYNC_KIND, SETTINGS_SYNC_D_TAG, + success_count, relay_count); } else { g_print("[settings_sync] No relays configured, event stored locally\n"); } @@ -221,6 +422,267 @@ static gboolean publish_timeout_cb(gpointer data) { return G_SOURCE_REMOVE; } +/* ── Merge helpers ──────────────────────────────────────────────────── */ + +/* Merge the "sovereign_browser" namespace from a decrypted payload into + * local db_kv + in-memory settings. */ +static void merge_sovereign_browser_namespace(const cJSON *sb) { + if (!cJSON_IsObject(sb)) return; + + /* Browser settings whitelist. */ + cJSON *item; + cJSON_ArrayForEach(item, sb) { + if (!cJSON_IsString(item)) continue; + for (int i = 0; i < g_sync_setting_count; i++) { + if (strcmp(item->string, g_sync_setting_keys[i]) == 0) { + db_kv_set(item->string, item->valuestring); + break; + } + } + } + + /* Per-app agent config. Only overwrite local values from Nostr if + * the local value is empty — this prevents a corrupted Nostr event + * from overwriting good local data. The local DB is the source of + * truth for per-app settings; Nostr is only used to bootstrap new + * devices. */ + cJSON *agent = cJSON_GetObjectItemCaseSensitive(sb, "agent"); + if (cJSON_IsObject(agent)) { + cJSON *j; + const char *existing; + + j = cJSON_GetObjectItemCaseSensitive(agent, "provider"); + if (cJSON_IsString(j) && j->valuestring[0]) { + existing = db_kv_get("agent.provider"); + if (!existing || !existing[0]) + db_kv_set("agent.provider", j->valuestring); + } + j = cJSON_GetObjectItemCaseSensitive(agent, "model"); + if (cJSON_IsString(j) && j->valuestring[0]) { + existing = db_kv_get("agent.llm_model"); + if (!existing || !existing[0]) + db_kv_set("agent.llm_model", j->valuestring); + } + j = cJSON_GetObjectItemCaseSensitive(agent, "system_prompt"); + if (cJSON_IsString(j) && j->valuestring[0]) { + existing = db_kv_get("agent.llm_system_prompt"); + if (!existing || !existing[0]) + db_kv_set("agent.llm_system_prompt", j->valuestring); + } + j = cJSON_GetObjectItemCaseSensitive(agent, "max_iterations"); + if (cJSON_IsNumber(j) && (int)j->valuedouble > 0) { + existing = db_kv_get("agent.max_iterations"); + if (!existing || !existing[0] || atoi(existing) <= 0) { + char buf[16]; + snprintf(buf, sizeof(buf), "%d", (int)j->valuedouble); + db_kv_set("agent.max_iterations", buf); + } + } + j = cJSON_GetObjectItemCaseSensitive(agent, "skill_name"); + if (cJSON_IsString(j) && j->valuestring[0]) { + existing = db_kv_get("agent.skill_name"); + if (!existing || !existing[0]) + db_kv_set("agent.skill_name", j->valuestring); + } + j = cJSON_GetObjectItemCaseSensitive(agent, "skill_description"); + if (cJSON_IsString(j) && j->valuestring[0]) { + existing = db_kv_get("agent.skill_description"); + if (!existing || !existing[0]) + db_kv_set("agent.skill_description", j->valuestring); + } + j = cJSON_GetObjectItemCaseSensitive(agent, "skill_template"); + if (cJSON_IsString(j) && j->valuestring[0]) { + existing = db_kv_get("agent.skill_template"); + if (!existing || !existing[0]) + db_kv_set("agent.skill_template", j->valuestring); + } + j = cJSON_GetObjectItemCaseSensitive(agent, "skill_requires_tools"); + if (cJSON_IsString(j) && j->valuestring[0]) { + existing = db_kv_get("agent.skill_requires_tools"); + if (!existing || !existing[0]) + db_kv_set("agent.skill_requires_tools", j->valuestring); + } + } + + /* Shortcuts. */ + cJSON *shortcuts_obj = cJSON_GetObjectItemCaseSensitive(sb, "shortcuts"); + if (cJSON_IsObject(shortcuts_obj)) { + shortcuts_merge_from_json(shortcuts_obj); + } + + /* Reload settings from db_kv into the in-memory singleton. */ + settings_load(); +} + +/* Merge the "global.agent" namespace from a decrypted payload into the + * in-memory provider catalog + resolved agent_llm_* fields. */ +static void merge_global_agent_namespace(const cJSON *agent_obj) { + if (!cJSON_IsObject(agent_obj)) return; + + browser_settings_t *s = settings_get_mutable(); + + /* providers array — only merge from Nostr if the local provider + * catalog is empty. This prevents a corrupted Nostr event from + * overwriting good local provider data. The local DB is the source + * of truth; Nostr is only used to bootstrap new devices. */ + cJSON *providers = cJSON_GetObjectItemCaseSensitive(agent_obj, "providers"); + if (cJSON_IsArray(providers) && s->agent_provider_count == 0) { + s->agent_provider_count = 0; + int n = cJSON_GetArraySize(providers); + if (n > SETTINGS_AGENT_MAX_PROVIDERS) n = SETTINGS_AGENT_MAX_PROVIDERS; + for (int i = 0; i < n; i++) { + cJSON *p = cJSON_GetArrayItem(providers, i); + if (!cJSON_IsObject(p)) continue; + agent_provider_t *dst = &s->agent_providers[i]; + memset(dst, 0, sizeof(*dst)); + + cJSON *j; + j = cJSON_GetObjectItemCaseSensitive(p, "name"); + if (cJSON_IsString(j)) { + snprintf(dst->name, sizeof(dst->name), "%s", j->valuestring); + } + j = cJSON_GetObjectItemCaseSensitive(p, "base_url"); + if (cJSON_IsString(j)) { + snprintf(dst->base_url, sizeof(dst->base_url), "%s", + j->valuestring); + } + j = cJSON_GetObjectItemCaseSensitive(p, "api_key"); + if (cJSON_IsString(j)) { + snprintf(dst->api_key, sizeof(dst->api_key), "%s", + j->valuestring); + } + cJSON *models = cJSON_GetObjectItemCaseSensitive(p, "models"); + if (cJSON_IsArray(models)) { + int mn = cJSON_GetArraySize(models); + if (mn > SETTINGS_AGENT_MAX_MODELS) mn = SETTINGS_AGENT_MAX_MODELS; + for (int m = 0; m < mn; m++) { + cJSON *mj = cJSON_GetArrayItem(models, m); + if (cJSON_IsString(mj)) { + snprintf(dst->models[m], sizeof(dst->models[m]), + "%s", mj->valuestring); + dst->model_count++; + } + } + } + s->agent_provider_count++; + } + } + + /* Resolve the active provider from agent.provider (in db_kv or the + * sovereign_browser namespace) and populate the resolved + * agent_llm_base_url / agent_llm_api_key fields. */ + const char *provider_name = db_kv_get("agent.provider"); + s->agent_active_provider = -1; + s->agent_active_provider_name[0] = '\0'; + if (provider_name && provider_name[0]) { + for (int i = 0; i < s->agent_provider_count; i++) { + if (strcmp(s->agent_providers[i].name, provider_name) == 0) { + s->agent_active_provider = i; + snprintf(s->agent_active_provider_name, + sizeof(s->agent_active_provider_name), "%s", + provider_name); + snprintf(s->agent_llm_base_url, sizeof(s->agent_llm_base_url), + "%s", s->agent_providers[i].base_url); + snprintf(s->agent_llm_api_key, sizeof(s->agent_llm_api_key), + "%s", s->agent_providers[i].api_key); + break; + } + } + } + + /* If no active provider was matched but we have providers, fall back + * to the first provider so the resolved base_url/api_key are usable. */ + if (s->agent_active_provider < 0 && s->agent_provider_count > 0) { + s->agent_active_provider = 0; + snprintf(s->agent_active_provider_name, + sizeof(s->agent_active_provider_name), "%s", + s->agent_providers[0].name); + snprintf(s->agent_llm_base_url, sizeof(s->agent_llm_base_url), + "%s", s->agent_providers[0].base_url); + snprintf(s->agent_llm_api_key, sizeof(s->agent_llm_api_key), + "%s", s->agent_providers[0].api_key); + } + + /* The model comes from the per-app sovereign_browser.agent.model + * (already in db_kv via merge_sovereign_browser_namespace, or from + * the local settings). settings_load() will pick it up. */ +} + +/* Migrate the old d:sovereign_browser event format (top-level "settings" + * and "shortcuts" keys, no "sovereign_browser" namespace) into the new + * v2 schema. Returns a newly allocated cJSON payload (caller must delete), + * or NULL if the payload is not the old format. */ +static cJSON *migrate_legacy_payload(const cJSON *payload) { + if (payload == NULL || !cJSON_IsObject(payload)) return NULL; + + /* The old format has a top-level "settings" object and no + * "sovereign_browser" namespace. */ + cJSON *old_settings = cJSON_GetObjectItemCaseSensitive(payload, "settings"); + cJSON *new_sb = cJSON_GetObjectItemCaseSensitive(payload, "sovereign_browser"); + if (!cJSON_IsObject(old_settings) || cJSON_IsObject(new_sb)) { + return NULL; /* not the old format */ + } + + /* Build a new v2 payload. */ + cJSON *v2 = cJSON_CreateObject(); + cJSON_AddNumberToObject(v2, "v", 2); + cJSON_AddNumberToObject(v2, "updatedAt", (double)time(NULL)); + + /* global.agent — seed from the old agent.* keys if present. */ + cJSON *global = cJSON_CreateObject(); + cJSON *agent = cJSON_CreateObject(); + cJSON *providers = cJSON_CreateArray(); + + /* Build a single provider from the old agent settings. */ + const browser_settings_t *s = settings_get(); + cJSON *pobj = cJSON_CreateObject(); + cJSON_AddStringToObject(pobj, "name", "default"); + cJSON_AddStringToObject(pobj, "base_url", s->agent_llm_base_url); + cJSON_AddStringToObject(pobj, "api_key", s->agent_llm_api_key); + cJSON *models = cJSON_CreateArray(); + if (s->agent_llm_model[0]) { + cJSON_AddItemToArray(models, cJSON_CreateString(s->agent_llm_model)); + } + cJSON_AddItemToObject(pobj, "models", models); + cJSON_AddItemToArray(providers, pobj); + cJSON_AddItemToObject(agent, "providers", providers); + cJSON *favorites = cJSON_CreateArray(); + if (s->agent_llm_model[0]) { + cJSON_AddItemToArray(favorites, cJSON_CreateString(s->agent_llm_model)); + } + cJSON_AddItemToObject(agent, "favorites", favorites); + cJSON_AddItemToObject(global, "agent", agent); + cJSON_AddItemToObject(v2, "global", global); + + /* sovereign_browser namespace — copy old settings + shortcuts. */ + cJSON *sb_ns = cJSON_Duplicate(old_settings, 1); + cJSON *old_shortcuts = cJSON_GetObjectItemCaseSensitive(payload, "shortcuts"); + if (cJSON_IsObject(old_shortcuts)) { + cJSON *sc_copy = cJSON_Duplicate(old_shortcuts, 1); + cJSON_AddItemToObject(sb_ns, "shortcuts", sc_copy); + } + /* Add per-app agent config from the old resolved fields. */ + cJSON *sb_agent = cJSON_CreateObject(); + cJSON_AddStringToObject(sb_agent, "provider", "default"); + cJSON_AddStringToObject(sb_agent, "model", s->agent_llm_model); + cJSON_AddStringToObject(sb_agent, "system_prompt", s->agent_llm_system_prompt); + cJSON_AddNumberToObject(sb_agent, "max_iterations", + (double)s->agent_max_iterations); + cJSON_AddStringToObject(sb_agent, "skill_name", s->agent_skill_name); + cJSON_AddStringToObject(sb_agent, "skill_description", + s->agent_skill_description); + cJSON_AddStringToObject(sb_agent, "skill_template", s->agent_skill_template); + cJSON_AddStringToObject(sb_agent, "skill_requires_tools", + s->agent_skill_requires_tools); + cJSON_AddItemToObject(sb_ns, "agent", sb_agent); + + cJSON_AddItemToObject(v2, "sovereign_browser", sb_ns); + + g_print("[settings_sync] Migrated legacy d:%s payload to v2 schema\n", + SETTINGS_SYNC_D_TAG_LEGACY); + return v2; +} + /* ── Public API ─────────────────────────────────────────────────────── */ void settings_sync_init(nostr_signer_t *signer, const char *pubkey_hex) { @@ -258,37 +720,29 @@ int settings_sync_merge_from_nostr(const void *event_cjson) { return -1; } - /* Verify the d-tag is "sovereign_browser". */ - cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags"); - if (!cJSON_IsArray(tags)) return -1; - - int is_ours = 0; - cJSON *tag; - cJSON_ArrayForEach(tag, tags) { - if (!cJSON_IsArray(tag)) continue; - cJSON *t0 = cJSON_GetArrayItem(tag, 0); - cJSON *t1 = cJSON_GetArrayItem(tag, 1); - if (cJSON_IsString(t0) && strcmp(t0->valuestring, "d") == 0 && - cJSON_IsString(t1) && - strcmp(t1->valuestring, SETTINGS_SYNC_D_TAG) == 0) { - is_ours = 1; - break; - } - } - if (!is_ours) return -1; + /* Verify the d-tag is "user-settings" (new) or "sovereign_browser" + * (legacy — will be migrated). */ + int is_new = event_has_d_tag(event, SETTINGS_SYNC_D_TAG); + int is_legacy = event_has_d_tag(event, SETTINGS_SYNC_D_TAG_LEGACY); + if (!is_new && !is_legacy) return -1; /* Get the event's created_at. */ cJSON *created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at"); if (!cJSON_IsNumber(created_at)) return -1; long event_ts = (long)created_at->valuedouble; - /* Compare to our last-synced timestamp. */ + /* Compare to our last-synced timestamp. We only skip the merge if the + * Nostr event is STRICTLY OLDER than our last sync — this prevents + * rolling back to stale data. If the timestamps are equal, we still + * merge because the local DB might be missing fields that are in the + * Nostr event (e.g., the API key was saved from another device and + * the local DB was created from a partial sync with the same timestamp). */ const char *ts_str = db_kv_get(SYNC_TS_KEY); long local_ts = 0; if (ts_str) local_ts = atol(ts_str); - if (event_ts <= local_ts) { - g_print("[settings_sync] Nostr event (%ld) not newer than local (%ld), " + if (event_ts < local_ts) { + g_print("[settings_sync] Nostr event (%ld) older than local (%ld), " "skipping merge\n", event_ts, local_ts); return 0; } @@ -309,28 +763,51 @@ int settings_sync_merge_from_nostr(const void *event_cjson) { return -1; } - /* Merge settings. */ - cJSON *settings_obj = cJSON_GetObjectItemCaseSensitive(payload, "settings"); - if (cJSON_IsObject(settings_obj)) { - cJSON *item; - cJSON_ArrayForEach(item, settings_obj) { - if (!cJSON_IsString(item)) continue; - /* Only merge whitelisted keys. */ - for (int i = 0; i < g_sync_setting_count; i++) { - if (strcmp(item->string, g_sync_setting_keys[i]) == 0) { - db_kv_set(item->string, item->valuestring); - break; - } - } + /* If this is a legacy event, migrate the payload to v2. */ + if (is_legacy) { + cJSON *migrated = migrate_legacy_payload(payload); + if (migrated) { + cJSON_Delete(payload); + payload = migrated; } - /* Reload settings from db_kv into the in-memory singleton. */ - settings_load(); } - /* Merge shortcuts. */ - cJSON *shortcuts_obj = cJSON_GetObjectItemCaseSensitive(payload, "shortcuts"); - if (cJSON_IsObject(shortcuts_obj)) { - shortcuts_merge_from_json(shortcuts_obj); + /* Merge global.agent (provider catalog). */ + cJSON *global = cJSON_GetObjectItemCaseSensitive(payload, "global"); + if (cJSON_IsObject(global)) { + cJSON *agent_obj = cJSON_GetObjectItemCaseSensitive(global, "agent"); + if (cJSON_IsObject(agent_obj)) { + merge_global_agent_namespace(agent_obj); + } + } + + /* Merge sovereign_browser namespace. */ + cJSON *sb = cJSON_GetObjectItemCaseSensitive(payload, "sovereign_browser"); + if (cJSON_IsObject(sb)) { + merge_sovereign_browser_namespace(sb); + } else if (is_legacy) { + /* Legacy payload that wasn't migrated (migrate_legacy_payload + * returned NULL because it didn't have the old "settings" key). + * Fall back to merging top-level "settings" + "shortcuts" as the + * old code did. */ + cJSON *old_settings = cJSON_GetObjectItemCaseSensitive(payload, "settings"); + if (cJSON_IsObject(old_settings)) { + cJSON *item; + cJSON_ArrayForEach(item, old_settings) { + if (!cJSON_IsString(item)) continue; + for (int i = 0; i < g_sync_setting_count; i++) { + if (strcmp(item->string, g_sync_setting_keys[i]) == 0) { + db_kv_set(item->string, item->valuestring); + break; + } + } + } + settings_load(); + } + cJSON *old_shortcuts = cJSON_GetObjectItemCaseSensitive(payload, "shortcuts"); + if (cJSON_IsObject(old_shortcuts)) { + shortcuts_merge_from_json(old_shortcuts); + } } /* Update the sync timestamp. */ @@ -338,7 +815,8 @@ int settings_sync_merge_from_nostr(const void *event_cjson) { snprintf(ts_buf, sizeof(ts_buf), "%ld", event_ts); db_kv_set(SYNC_TS_KEY, ts_buf); - g_print("[settings_sync] Merged settings from Nostr event (ts=%ld)\n", + g_print("[settings_sync] Merged settings from Nostr event (d:%s, ts=%ld)\n", + is_legacy ? SETTINGS_SYNC_D_TAG_LEGACY : SETTINGS_SYNC_D_TAG, event_ts); cJSON_Delete(payload); diff --git a/src/settings_sync.h b/src/settings_sync.h index d723440..014c28c 100644 --- a/src/settings_sync.h +++ b/src/settings_sync.h @@ -2,14 +2,19 @@ * settings_sync.h — NIP-78 (kind 30078) settings sync for sovereign_browser * * Syncs user-configurable, device-independent settings across all devices - * the user logs in on. Uses a single kind 30078 addressable event with - * d-tag "sovereign_browser". The content is NIP-44 encrypted (self-to-self) - * JSON containing whitelisted settings + all keyboard shortcut bindings. + * the user logs in on. Uses a single shared kind 30078 addressable event + * with d-tag "user-settings" (the same event used by ~/lt/client and + * ~/lt/didactyl). The content is NIP-44 encrypted (self-to-self) JSON + * with a "global" namespace (shared agent provider catalog) and per-app + * namespaces (e.g. "sovereign_browser", "client", "didactyl"). + * + * sovereign_browser writes only the "sovereign_browser" and "global.agent" + * namespaces; other apps' namespaces are preserved via read-modify-write. * * Device-specific settings (agent port, allowed origins, session restore, * security toggles) are NOT synced — they stay local only. * - * See plans/keyboard-shortcuts.md for the full design. + * See plans/cross-project-agent-sync.md for the full design. */ #ifndef SETTINGS_SYNC_H @@ -24,8 +29,12 @@ extern "C" { #endif -/* The d-tag identifier used for our kind 30078 event. */ -#define SETTINGS_SYNC_D_TAG "sovereign_browser" +/* The d-tag identifier used for the shared kind 30078 user-settings event. + * This is shared with ~/lt/client and ~/lt/didactyl. */ +#define SETTINGS_SYNC_D_TAG "user-settings" + +/* The legacy d-tag used before the migration to the shared event. */ +#define SETTINGS_SYNC_D_TAG_LEGACY "sovereign_browser" /* The Nostr kind for arbitrary custom app data (NIP-78). */ #define SETTINGS_SYNC_KIND 30078 @@ -59,7 +68,8 @@ void settings_sync_publish(void); * overwrites local db_kv values and reloads in-memory bindings. * * event — a cJSON object representing a kind 30078 event with - * d-tag "sovereign_browser" + * d-tag "user-settings" (or the legacy "sovereign_browser" + * d-tag, which is migrated to the new format on merge). * * Returns 0 on success, -1 on error / not applicable. */ diff --git a/src/shortcuts.c b/src/shortcuts.c index 3f427f4..35967fd 100644 --- a/src/shortcuts.c +++ b/src/shortcuts.c @@ -87,6 +87,10 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = { "toggle_inspector", "Toggle inspector", "Show or hide the WebKit Web Inspector", "i" }, + [SHORTCUT_TOGGLE_SIDEBAR] = { + "toggle_sidebar", "Toggle agent sidebar", + "Show or hide the agent chat sidebar", "a" + }, }; /* ── In-memory parsed bindings ──────────────────────────────────────── */ diff --git a/src/shortcuts.h b/src/shortcuts.h index 7797037..1ef6940 100644 --- a/src/shortcuts.h +++ b/src/shortcuts.h @@ -43,6 +43,7 @@ typedef enum { SHORTCUT_NEW_IDENTITY, SHORTCUT_TOGGLE_FULLSCREEN, SHORTCUT_TOGGLE_INSPECTOR, + SHORTCUT_TOGGLE_SIDEBAR, SHORTCUT_COUNT } shortcut_action_t; diff --git a/src/tab_manager.c b/src/tab_manager.c index 2a14b3f..7e5b9b8 100644 --- a/src/tab_manager.c +++ b/src/tab_manager.c @@ -16,6 +16,7 @@ #include "version.h" #include "agent_snapshot.h" /* agent_js_eval_sync() for clear-and-reload */ #include "db.h" +#include "agent_chat.h" /* agent_chat_route_input() for ";" URL-bar shortcut */ #include #include /* strcasecmp */ @@ -49,6 +50,7 @@ extern void app_menu_nostr_sign_proxy(GtkMenuItem *item, gpointer data); extern void app_menu_about_proxy(GtkMenuItem *item, gpointer data); extern void on_menu_settings(GtkMenuItem *item, gpointer data); extern void on_menu_profile(GtkMenuItem *item, gpointer data); +extern void on_menu_agent(GtkMenuItem *item, gpointer data); /* Key press handler defined in main.c — connected to each webview so * keyboard shortcuts are caught before WebKit consumes the event. */ @@ -840,6 +842,12 @@ static GtkWidget *tab_manager_new_window(const char *url, /* Show the tab widgets before switching to it. */ gtk_widget_show_all(tab->page); gtk_widget_show_all(tab->tab_label); + + /* Re-hide the sidebar container after show_all (see + * tab_manager_new_tab for the rationale — Issue 2). */ + if (tab->sidebar_container) { + gtk_widget_hide(tab->sidebar_container); + } gtk_notebook_set_current_page(GTK_NOTEBOOK(notebook), page_num); /* Window lifecycle: focus-in updates the active window/notebook @@ -913,6 +921,18 @@ static GtkWidget *build_tab_label(tab_info_t *tab) { static void on_url_activate(GtkEntry *entry, gpointer user_data) { tab_info_t *tab = (tab_info_t *)user_data; const char *text = gtk_entry_get_text(entry); + + /* Agent chat shortcut: "; " routes to the embedded agent. */ + if (text[0] == ';') { + const char *msg = text + 1; + /* Skip leading whitespace after the semicolon. */ + while (*msg == ' ' || *msg == '\t') msg++; + agent_chat_route_input(*msg ? msg : NULL); + /* Clear the URL bar after sending to agent. */ + gtk_entry_set_text(entry, ""); + return; + } + char *url = normalize_url(text); if (url != NULL) { webkit_web_view_load_uri(tab->webview, url); @@ -1288,6 +1308,12 @@ static void on_menu_inspector(GtkMenuItem *item, gpointer data) { tab_manager_toggle_inspector(); } +static void on_menu_toggle_sidebar(GtkMenuItem *item, gpointer data) { + (void)item; + (void)data; + tab_manager_toggle_sidebar(); +} + static void on_menu_open_file(GtkMenuItem *item, gpointer data) { (void)item; (void)data; @@ -1686,11 +1712,21 @@ static GtkWidget *build_hamburger_menu(tab_info_t *tab) { G_CALLBACK(on_menu_inspector), NULL); gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_inspector); + GtkWidget *item_sidebar = gtk_menu_item_new_with_label("Toggle Agent Sidebar"); + g_signal_connect(item_sidebar, "activate", + G_CALLBACK(on_menu_toggle_sidebar), NULL); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_sidebar); + GtkWidget *item_profile = gtk_menu_item_new_with_label("Profile"); g_signal_connect(item_profile, "activate", G_CALLBACK(on_menu_profile), g_window); gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_profile); + GtkWidget *item_agent = gtk_menu_item_new_with_label("Agent Setup…"); + g_signal_connect(item_agent, "activate", + G_CALLBACK(on_menu_agent), g_window); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_agent); + GtkWidget *item_settings = gtk_menu_item_new_with_label("Settings…"); g_signal_connect(item_settings, "activate", G_CALLBACK(on_menu_settings), g_window); @@ -1965,8 +2001,33 @@ static tab_info_t *tab_create(const char *url) { * display servers, resulting in a blank page. */ gtk_widget_set_vexpand(GTK_WIDGET(tab->webview), TRUE); gtk_widget_set_hexpand(GTK_WIDGET(tab->webview), TRUE); - gtk_box_pack_start(GTK_BOX(tab->page), GTK_WIDGET(tab->webview), - TRUE, TRUE, 0); + + /* Build a horizontal GtkPaned: left = sidebar container, right = + * main webview. Both children are always packed (no add/remove + * which caused a crash). When hidden, the position is set to 0 + * and the sidebar widget is hidden — the divider disappears at + * position 0. When shown, the position is set to 280 and the + * sidebar is shown — the divider appears and is resizable. */ + tab->paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL); + gtk_widget_set_vexpand(tab->paned, TRUE); + gtk_widget_set_hexpand(tab->paned, TRUE); + + /* Sidebar container — packed as first (left) pane. Hidden by + * default. No size request (the paned position controls width). */ + tab->sidebar_container = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_paned_pack1(GTK_PANED(tab->paned), tab->sidebar_container, + FALSE, FALSE); + gtk_widget_hide(tab->sidebar_container); + tab->sidebar_visible = FALSE; + tab->sidebar_webview = NULL; + + /* Main webview — packed as second (right) pane. */ + gtk_paned_pack2(GTK_PANED(tab->paned), GTK_WIDGET(tab->webview), + TRUE, TRUE); + /* Position 0 = sidebar gets no space (hidden by default). */ + gtk_paned_set_position(GTK_PANED(tab->paned), 0); + + gtk_box_pack_start(GTK_BOX(tab->page), tab->paned, TRUE, TRUE, 0); /* Build the tab label. */ tab->tab_label = build_tab_label(tab); @@ -2334,6 +2395,15 @@ int tab_manager_new_tab(const char *url) { gtk_widget_show_all(tab->page); gtk_widget_show_all(tab->tab_label); + /* gtk_widget_show_all() recursively shows all children, including + * the sidebar container that tab_create() explicitly hid. Re-hide + * the sidebar so it stays hidden by default — it should only appear + * when the user toggles it via Ctrl+Shift+A, the menu item, or the + * ";" URL-bar shortcut. (Issue 2: sidebar visible on startup.) */ + if (tab->sidebar_container) { + gtk_widget_hide(tab->sidebar_container); + } + /* Switch to the new tab (now that it's visible). */ gtk_notebook_set_current_page(GTK_NOTEBOOK(nb), page_num); @@ -2518,3 +2588,83 @@ void tab_manager_reload(int index) { webkit_web_view_reload_bypass_cache(tab->webview); } } + +/* ── Agent chat sidebar (split view) ─────────────────────────────── */ + +#define SIDEBAR_DEFAULT_WIDTH 280 +#define AGENT_CHAT_URL_STR "sovereign://agents/chat" + +/* Lazily create the sidebar webview for a tab. The webview shares the + * same WebKitWebContext as the main webview so the sovereign:// scheme + * works. It is packed into the sidebar container (the left pane of the + * GtkPaned). Called the first time the sidebar is shown for a tab. */ +static void sidebar_create_webview(tab_info_t *tab) { + if (tab == NULL || tab->sidebar_webview != NULL) return; + if (g_ctx == NULL) return; + + tab->sidebar_webview = WEBKIT_WEB_VIEW( + webkit_web_view_new_with_context(g_ctx)); + + /* Match the main webview's settings so JS and the sovereign:// + * bridge work. */ + WebKitSettings *st = webkit_web_view_get_settings(tab->sidebar_webview); + webkit_settings_set_enable_developer_extras(st, TRUE); + webkit_settings_set_enable_javascript(st, TRUE); + webkit_settings_set_allow_file_access_from_file_urls(st, TRUE); + webkit_settings_set_allow_universal_access_from_file_urls(st, TRUE); + + /* Inject window.nostr so the chat page's NIP-07 shim works. */ + nostr_inject_setup(tab->sidebar_webview); + + /* The sidebar webview should NOT create new windows/tabs — links + * in the chat page should navigate within the sidebar, not open + * new browser tabs. We let the default navigation happen inside + * the sidebar webview. */ + + gtk_widget_set_vexpand(GTK_WIDGET(tab->sidebar_webview), TRUE); + gtk_widget_set_hexpand(GTK_WIDGET(tab->sidebar_webview), TRUE); + + /* Pack into the sidebar container. */ + gtk_box_pack_start(GTK_BOX(tab->sidebar_container), + GTK_WIDGET(tab->sidebar_webview), TRUE, TRUE, 0); + + /* Load the chat page. */ + webkit_web_view_load_uri(tab->sidebar_webview, AGENT_CHAT_URL_STR); +} + +void tab_manager_toggle_sidebar(void) { + tab_info_t *tab = tab_manager_get_active(); + if (tab == NULL || tab->paned == NULL) return; + + if (tab->sidebar_visible) { + /* Hide the sidebar. Set position to 0 so the sidebar gets no + * space and the divider disappears. Keep the webview alive. */ + gtk_paned_set_position(GTK_PANED(tab->paned), 0); + gtk_widget_hide(tab->sidebar_container); + tab->sidebar_visible = FALSE; + } else { + /* Show the sidebar. Create the webview lazily on first show. + * Set position to 280 so the sidebar gets space and the + * divider appears (resizable). */ + if (tab->sidebar_webview == NULL) { + sidebar_create_webview(tab); + } + gtk_paned_set_position(GTK_PANED(tab->paned), + SIDEBAR_DEFAULT_WIDTH); + gtk_widget_show_all(tab->sidebar_container); + tab->sidebar_visible = TRUE; + } +} + +WebKitWebView *tab_manager_get_main_webview(void) { + tab_info_t *tab = tab_manager_get_active(); + if (tab == NULL) return NULL; + /* Always return the main webview, never the sidebar. */ + return tab->webview; +} + +gboolean tab_manager_sidebar_visible(void) { + tab_info_t *tab = tab_manager_get_active(); + if (tab == NULL) return FALSE; + return tab->sidebar_visible; +} diff --git a/src/tab_manager.h b/src/tab_manager.h index f75b4b9..c3aa5ee 100644 --- a/src/tab_manager.h +++ b/src/tab_manager.h @@ -30,6 +30,15 @@ typedef struct { GtkWidget *favicon; /* favicon image in tab_label */ char current_url[TAB_URL_MAX]; char title[TAB_TITLE_MAX]; + /* Agent chat sidebar (split view). The sidebar is a second webview + * showing sovereign://agents/chat, shown alongside the main webview + * in a GtkPaned. When sidebar_visible is FALSE, the sidebar + * container is hidden but the webview is kept alive so chat state + * persists across toggles. */ + WebKitWebView *sidebar_webview; /* agent chat sidebar (NULL if not yet created) */ + GtkWidget *sidebar_container; /* GtkScrolledWindow holding sidebar_webview */ + GtkWidget *paned; /* GtkPaned: sidebar | main webview */ + gboolean sidebar_visible; /* whether the sidebar is currently shown */ } tab_info_t; /* @@ -160,6 +169,27 @@ void tab_manager_set_avatar(const char *pubkey_hex); */ void tab_manager_toggle_inspector(void); +/* + * Toggle the agent chat sidebar for the active tab. When showing, if + * the sidebar webview has not been created yet, it is created (sharing + * the same WebKitWebContext so sovereign:// works) and loads + * sovereign://agents/chat. When hiding, the sidebar container is + * hidden but the webview is kept alive so chat state persists. + */ +void tab_manager_toggle_sidebar(void); + +/* + * Returns the main webview (the web page) of the active tab, never the + * sidebar webview. This is used by agent tools so they always operate + * on the web page even when the sidebar is open and focused. + */ +WebKitWebView *tab_manager_get_main_webview(void); + +/* + * Returns TRUE if the sidebar is currently visible for the active tab. + */ +gboolean tab_manager_sidebar_visible(void); + #ifdef __cplusplus } #endif diff --git a/src/version.h b/src/version.h index f76a26c..6d2b301 100644 --- a/src/version.h +++ b/src/version.h @@ -11,9 +11,9 @@ #ifndef SOVEREIGN_BROWSER_VERSION_H #define SOVEREIGN_BROWSER_VERSION_H -#define SB_VERSION "v0.0.26" +#define SB_VERSION "v0.0.27" #define SB_VERSION_MAJOR 0 #define SB_VERSION_MINOR 0 -#define SB_VERSION_PATCH 26 +#define SB_VERSION_PATCH 27 #endif /* SOVEREIGN_BROWSER_VERSION_H */ diff --git a/www/agents/chat.css b/www/agents/chat.css new file mode 100644 index 0000000..9bb3273 --- /dev/null +++ b/www/agents/chat.css @@ -0,0 +1,442 @@ +/* Shared sovereign:// page CSS (theme variables, base elements, buttons). + * Mirrors sovereign_page_css() in src/nostr_bridge.c so the chat page + * stays consistent with settings/profile/bookmarks pages. */ +:root { + --font: monospace; + --primary: #000000; + --secondary: #ffffff; + --accent: #ff0000; + --muted: #dddddd; + --border: var(--muted); + --radius: 5px; + --bg: var(--secondary); + --fg: var(--primary); + --img-gray: 100%; + --img-gray-hover: 20%; + color-scheme: light; +} +html.dark { + --primary: #ffffff; + --secondary: #000000; + --accent: #ff0000; + --muted: #777777; + --border: var(--muted); + color-scheme: dark; +} +* { font-family: var(--font); margin: 0; padding: 0; box-sizing: border-box; } +html { transition: background-color 0.2s ease, color 0.2s ease; } +body { color: var(--fg); background: var(--bg); line-height: 1.5; } +a { color: var(--accent); text-decoration: none; transition: 0.2s; } +a:hover { text-decoration: underline; } +input, select, textarea { + background: var(--bg); color: var(--fg); + border: 1px solid var(--border); border-radius: var(--radius); + padding: 6px 10px; font-family: var(--font); font-size: 13px; + min-width: 200px; +} +input:focus, select:focus, textarea:focus { + border-color: var(--accent); outline: none; +} +.btn, .save-btn { + background: var(--bg); color: var(--primary); + border: 1px solid var(--primary); border-radius: var(--radius); + padding: 6px 16px; cursor: pointer; font-family: var(--font); + font-size: 13px; font-weight: bold; margin-left: 8px; + transition: border-color 0.2s, background 0.2s, color 0.2s; +} +.btn:hover, .save-btn:hover { border-color: var(--accent); } +.btn:active, .save-btn:active { background: var(--accent); color: var(--secondary); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-del { border-color: var(--accent); color: var(--accent); } +.btn-del:hover { background: var(--accent); color: var(--secondary); } + +/* ── Chat page layout (tabbed) ───────────────────────────────── */ +#chat-root { + display: flex; flex-direction: column; + height: 100vh; padding: 8px; +} +/* Tab bar — horizontal row of tab buttons. */ +#tab-bar { + display: flex; flex-direction: row; gap: 0; + border-bottom: 1px solid var(--border); + flex-shrink: 0; margin-bottom: 8px; +} +.tab { + background: transparent; color: var(--fg); + border: none; border-bottom: 2px solid transparent; + padding: 8px 14px; cursor: pointer; + font-family: var(--font); font-size: 13px; font-weight: bold; + margin-left: 0; transition: border-color 0.15s, color 0.15s; +} +.tab:hover { color: var(--accent); } +.tab.active { + border-bottom-color: var(--accent); + color: var(--accent); +} +/* Tab content — only the active tab is visible. */ +.tab-content { + display: none; flex: 1; min-height: 0; + flex-direction: column; +} +.tab-content.active { display: flex; } +/* Toolbar inside a tab (refresh + action buttons). */ +.tab-toolbar { + display: flex; flex-direction: row; gap: 6px; + flex-shrink: 0; margin-bottom: 8px; +} +.tab-toolbar .btn { margin-left: 0; } +#new-chat-btn { margin-left: 0; } +#conv-list { + flex: 1; overflow-y: auto; display: flex; + flex-direction: column; gap: 4px; +} +/* Conversation list items — match ai.html conversation styling: + * title + timestamp on the left, delete button appears on hover. */ +.conv-item { + display: flex; align-items: center; gap: 6px; + padding: 8px 10px; border-radius: var(--radius); cursor: pointer; + border: 1px solid transparent; + transition: background 0.15s, border-color 0.15s; +} +.conv-item:hover { + background: rgba(128,128,128,0.08); +} +.conv-active { + border-color: var(--accent); + background: rgba(255,0,0,0.06); +} +.conv-item-content { + flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; +} +.conv-title { + overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; font-size: 0.9em; color: var(--fg); +} +.conv-timestamp { + font-size: 0.72em; color: var(--muted); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.conv-del { + flex-shrink: 0; padding: 2px 8px; font-size: 0.75em; + opacity: 0; transition: opacity 0.15s; +} +.conv-item:hover .conv-del { opacity: 1; } +.conv-empty { color: var(--muted); font-size: 0.85em; padding: 8px; } +#create-skill-btn { margin-left: 0; } +/* Rename button — same hover-reveal behavior as the delete button. */ +.conv-rename { + flex-shrink: 0; padding: 2px 8px; font-size: 0.75em; + opacity: 0; transition: opacity 0.15s; margin-left: 0; + border-color: var(--border); color: var(--fg); +} +.conv-rename:hover { border-color: var(--accent); color: var(--accent); } +.conv-item:hover .conv-rename { opacity: 1; } +/* "No chat selected" placeholder shown in the chat pane when no + * conversation is active. */ +.no-chat-placeholder { + color: var(--muted); font-style: italic; font-size: 0.9em; + padding: 16px; text-align: center; +} +#skill-list { + flex: 1; overflow-y: auto; display: flex; + flex-direction: column; gap: 4px; +} +/* Skills list items — match ai.html skills styling: + * checkbox + skill name + tool badges, with delete on hover for + * user-authored skills. */ +.skill-item { + display: flex; align-items: center; gap: 6px; + padding: 6px 8px; border-radius: var(--radius); flex-wrap: wrap; + border: 1px solid transparent; + transition: background 0.15s, border-color 0.15s; +} +.skill-item:hover { + background: rgba(128,128,128,0.06); + border-color: var(--border); +} +.skill-checkbox { flex-shrink: 0; margin: 0; min-width: auto; } +.skill-name { font-size: 0.88em; flex-shrink: 0; color: var(--fg); } +.skill-badge { + font-size: 0.7em; padding: 1px 6px; border-radius: 3px; + white-space: nowrap; +} +.skill-badge-tools { + background: rgba(0,180,0,0.15); color: #2a2; + border: 1px solid rgba(0,180,0,0.4); +} +html.dark .skill-badge-tools { + background: rgba(0,180,0,0.2); color: #6c6; + border-color: rgba(0,180,0,0.5); +} +.skill-tool-enabled { border-left: 3px solid rgba(0,180,0,0.5); } +.skill-item .conv-del { + opacity: 0; padding: 1px 6px; font-size: 0.75em; +} +.skill-item:hover .conv-del { opacity: 1; } + +/* ── Skill editor (inline, matching ai.html) ──────────────────── */ +.skill-badge-unsaved { + background: rgba(255,165,0,0.15); color: #a60; + border: 1px solid rgba(255,165,0,0.4); +} +html.dark .skill-badge-unsaved { + background: rgba(255,165,0,0.2); color: #fc6; + border-color: rgba(255,165,0,0.5); +} +.skillStackedItem { + border: 1px solid var(--border); border-radius: var(--radius); + padding: 6px 8px; margin-bottom: 4px; + background: rgba(128,128,128,0.04); +} +.skillStackedItem > summary { + display: flex; align-items: center; gap: 6px; + cursor: pointer; flex-wrap: wrap; list-style: none; + user-select: none; +} +.skillStackedItem > summary::-webkit-details-marker { display: none; } +.skillStackedItem > summary .skill-checkbox { + flex-shrink: 0; margin: 0; min-width: auto; +} +.skillStackedItem > summary .skill-name { + font-size: 0.88em; flex-shrink: 0; color: var(--fg); +} +.aiSkillControlsRow { + display: flex; flex-direction: column; gap: 6px; + margin-top: 8px; padding-top: 6px; + border-top: 1px solid var(--border); +} +.aiSkillInlineField { + display: flex; flex-direction: column; gap: 2px; +} +.aiSkillInlineField label { + font-size: 0.75em; color: var(--muted); font-weight: bold; +} +.aiInput { + background: var(--bg); color: var(--fg); + border: 1px solid var(--border); border-radius: var(--radius); + padding: 4px 6px; font-family: var(--font); font-size: 0.85em; + min-width: 0; width: 100%; box-sizing: border-box; +} +.aiInput:focus { border-color: var(--accent); outline: none; } +.taSkillTemplateEditor { + background: var(--bg); color: var(--fg); + border: 1px solid var(--border); border-radius: var(--radius); + padding: 4px 6px; font-family: var(--font); font-size: 0.85em; + width: 100%; box-sizing: border-box; min-height: 80px; + resize: vertical; +} +.taSkillTemplateEditor:focus { border-color: var(--accent); outline: none; } +.aiSkillActionsRow { + display: flex; gap: 6px; margin-top: 6px; +} +.aiSkillActionsRow .btn { margin-left: 0; } +.aiSkillActionsRow .btn:disabled { opacity: 0.5; cursor: not-allowed; } +#create-skill-form { + display: flex; flex-direction: column; gap: 4px; + margin-top: 4px; padding: 8px; + border: 1px solid var(--border); border-radius: var(--radius); + background: rgba(128,128,128,0.05); +} +.skill-input { + width: 100%; box-sizing: border-box; + font-family: var(--font); font-size: 0.85em; padding: 4px 6px; + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--bg); color: var(--fg); +} +#skill-content { resize: vertical; min-height: 60px; } +/* The chat tab content fills the available height: messages scroll, + * input pinned to the bottom. */ +#tab-chat { min-width: 0; } +#messages { + flex: 1; overflow-y: auto; border: 1px solid #444; + padding: 8px; margin-bottom: 8px; background: rgba(0,0,0,0.05); +} +/* ── Message bubbles (ported from ai.html messaging-ui.css) ────── */ +.msg-bubble-row { + display: flex; width: 100%; +} +.msg-bubble-row.outgoing { justify-content: flex-end; } +.msg-bubble-row.incoming, +.msg-bubble-row.system { justify-content: flex-start; } +.msg-bubble { + max-width: 75%; + border: 1px solid var(--primary); + border-radius: 10px; + padding: 8px 10px; + white-space: normal; + word-wrap: break-word; + overflow-wrap: break-word; + font-size: 90%; + color: var(--fg); + background: var(--bg); + margin-bottom: 10px; +} +.msg-bubble.outgoing { border-color: var(--primary); } +.msg-bubble.incoming { border-color: var(--muted); } +.msg-bubble.system { + border-style: dashed; + border-color: var(--muted); + color: var(--muted); + font-size: 82%; +} +.msg-bubble-header { + display: flex; align-items: center; + justify-content: space-between; gap: 8px; margin-bottom: 6px; +} +.msg-bubble-header-main { + min-width: 0; flex: 1; display: flex; + align-items: center; gap: 8px; +} +.msg-bubble-sender-meta { + min-width: 0; display: flex; flex-direction: column; gap: 1px; +} +.msg-bubble-sender-name { + font-size: 78%; color: var(--fg); font-weight: bold; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.msg-bubble-menu-host { flex-shrink: 0; } +.msg-bubble-content { line-height: 1.35; } +.msg-bubble-content > *:first-child { margin-top: 0; } +.msg-bubble-content > *:last-child { margin-bottom: 0; } +.msg-bubble-content p { margin: 0 0 0.3em 0; } +.msg-bubble-content h1, .msg-bubble-content h2, .msg-bubble-content h3, +.msg-bubble-content h4, .msg-bubble-content h5, .msg-bubble-content h6 { + margin: 0.4em 0 0.2em 0; font-size: 1em; +} +.msg-bubble-content code { + background: rgba(128,128,128,0.18); + border-radius: 4px; padding: 1px 4px; font-size: 85%; +} +.msg-bubble-content pre { + background: rgba(0,0,0,0.15); + border-radius: 6px; padding: 6px 8px; margin: 4px 0; + overflow-x: auto; +} +.msg-bubble-content pre code { + background: transparent; padding: 0; + border-radius: 0; font-size: 90%; +} +.msg-bubble-content blockquote { + margin: 4px 0; padding: 2px 8px; + border-left: 3px solid var(--muted); + color: var(--muted); +} +.msg-bubble-content a { + color: var(--accent); text-decoration: underline; word-break: break-all; +} +.msg-bubble-content img { + display: block; max-width: 100%; + border-radius: 6px; margin: 4px 0; +} +.msg-bubble-content ul, .msg-bubble-content ol { + margin: 4px 0; padding-left: 20px; +} +.msg-bubble-content table { + width: 100%; border-collapse: collapse; + margin: 6px 0; font-size: 90%; +} +.msg-bubble-content th, .msg-bubble-content td { + border: 1px solid var(--border); + padding: 4px 8px; text-align: left; vertical-align: top; +} +.msg-bubble-content thead th { background: rgba(128,128,128,0.12); } +.msg-bubble-content tbody tr:nth-child(even) { background: rgba(128,128,128,0.06); } +.msg-bubble-content del, .msg-bubble-content s { opacity: 0.75; } +.msg-bubble-content strong { font-weight: bold; } +.msg-tcid { font-size: 0.8em; color: var(--muted); margin-top: 4px; } + +/* ── Dot-menu ──────────────────────────────────────────────────── + * The dot-menu styles now come from the shared sovereign://css/dot-menu.css + * (copied from the client). Only the host sizing is page-specific. */ +.msg-bubble-menu-host .dot-menu-wrap { flex-shrink: 0; } + +/* ── Tool calls, status message, input area ───────────────────── */ +.tool-calls { margin-top: 4px; } +.tool-calls pre { + font-size: 0.85em; white-space: pre-wrap; word-wrap: break-word; +} + +/* Status message — a temporary system bubble shown in the chat window + * (replaces the old #status-bar). It is italic and muted to distinguish + * it from real agent responses. */ +#status-msg { + margin-bottom: 8px; +} +#status-msg-bubble { + font-style: italic; color: var(--muted); + max-width: 90%; border-style: dashed; +} + +/* The input area is a flex row: the composer (textarea + send button) + * on the left taking all available space, and the Stop button on the + * right (only visible while the agent is running). */ +#input-area { + display: flex; align-items: flex-end; gap: 8px; + border: 1px solid var(--border); border-radius: var(--radius); + padding: 8px; background: var(--bg); +} +#composer-host { + flex: 1; min-width: 0; +} +#stop-btn { + flex-shrink: 0; min-width: 80px; margin-left: 0; +} + +/* The shared post-composer library wraps the host element: it inserts a + * .post-composer-wrapper around #composer-host and appends the send + * button as a SIBLING of the host (inside the wrapper). So the send + * button is NOT a descendant of #composer-host — it lives in + * #input-area > .post-composer-wrapper > [.post-composer-editor-shell > + * #composer-host, .post-composer-send-btn]. Target it via #input-area. + * Make the wrapper fill the row and ensure the send button has a + * visible border matching the page .btn style (post-composer.css uses + * --primary-color which is undefined here, so we override it). */ +#input-area .post-composer-wrapper { + width: 100%; min-width: 0; max-width: none; margin-bottom: 0; + border: none; padding: 0; background: transparent; + display: flex; flex-direction: column; gap: 0; +} +#input-area .post-composer-send-btn { + margin-left: 0; min-width: 80px; + border: 1px solid var(--primary); + color: var(--primary); +} +#input-area .post-composer-send-btn:hover, +#input-area .post-composer-send-btn:focus { + border-color: var(--accent); +} +#input-area .post-composer-send-btn:disabled { + opacity: 0.5; cursor: not-allowed; +} + +/* ── Responsive: narrow sidebar viewport (< 400px) ────────────── + * When the chat page is loaded in the agent sidebar (~350px wide), + * switch to a compact layout: smaller fonts, shorter tab labels, + * full-width messages, tighter padding. */ +@media (max-width: 400px) { + #chat-root { padding: 4px; } + .tab { + padding: 6px 8px; font-size: 12px; + } + /* Shorten tab labels to single characters in narrow view. */ + .tab[data-tab="chat"] { font-size: 0; } + .tab[data-tab="chat"]::before { content: "💬"; font-size: 14px; } + .tab[data-tab="conversations"] { font-size: 0; } + .tab[data-tab="conversations"]::before { content: "☰"; font-size: 14px; } + .tab[data-tab="skills"] { font-size: 0; } + .tab[data-tab="skills"]::before { content: "⚙"; font-size: 14px; } + #messages { + padding: 4px; font-size: 12px; + } + .msg-bubble { + max-width: 92%; font-size: 85%; padding: 6px 8px; + } + .msg-bubble.system { font-size: 78%; } + #input-area { padding: 4px; gap: 4px; } + .tab-toolbar { gap: 4px; } + .tab-toolbar .btn { padding: 4px 10px; font-size: 12px; } + .conv-item { padding: 6px 8px; } + .conv-title { font-size: 0.85em; } + .skill-name { font-size: 0.82em; } + .skill-input { font-size: 12px; } +} diff --git a/www/agents/chat.html b/www/agents/chat.html new file mode 100644 index 0000000..1a84b8e --- /dev/null +++ b/www/agents/chat.html @@ -0,0 +1,63 @@ + + + + + + Agent Chat + + + + + +
+
+ + + +
+ +
+
+
+ No conversations yet. Click ‘+ New Chat’ to start. +
+ +
+
+ +
+
+ +
+
+ + +
+
+
+ +
+
+ + +
+
+ +
+
+ + + + + + + diff --git a/www/agents/chat.js b/www/agents/chat.js new file mode 100644 index 0000000..6f3aa2a --- /dev/null +++ b/www/agents/chat.js @@ -0,0 +1,998 @@ +/* Agent Chat — sovereign://agents/chat + * + * Two-pane layout: conversations + skills (left), chat thread (right). + * + * Uses the shared dot-menu and post-composer libraries (loaded as regular + * + + diff --git a/www/agents/config.js b/www/agents/config.js new file mode 100644 index 0000000..60bb03a --- /dev/null +++ b/www/agents/config.js @@ -0,0 +1,289 @@ +/* Agent config page — sovereign://agents + * + * Fetches the current agent config from sovereign://agents/config (JSON) + * on page load and populates the form. Saves use sovereign://agents/set. + * Model refresh uses sovereign://agents/models. + */ +var savedModel = ''; + +function sovereignGet(url) { + return new Promise(function(resolve, reject) { + var x = new XMLHttpRequest(); + x.open('GET', url, true); + x.onreadystatechange = function() { + if (x.readyState !== 4) return; + try { resolve(JSON.parse(x.responseText)); } + catch (e) { reject(e); } + }; + x.onerror = function() { reject(new Error('XHR error')); }; + x.send(); + }); +} + +function showStatus(cls, msg) { + var s = document.getElementById('status'); + s.className = 'status ' + cls + ' show'; + s.textContent = msg; +} + +function toggleKeyHide() { + var cb = document.getElementById('api-key-hide'); + var ak = document.getElementById('agent.llm_api_key'); + ak.type = cb.checked ? 'password' : 'text'; +} + +function applyConfig(d) { + /* Provider dropdown. */ + var sel = document.getElementById('agent.provider'); + sel.innerHTML = ''; + (d.providers || []).forEach(function(name, i) { + var opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + if (i === d.active_provider) opt.selected = true; + sel.appendChild(opt); + }); + + /* Active provider fields. */ + document.getElementById('provider_name').value = d.provider_name || ''; + document.getElementById('agent.llm_base_url').value = d.base_url || ''; + document.getElementById('agent.llm_api_key').value = d.api_key || ''; + + /* Model dropdown. */ + savedModel = d.model || ''; + populateModels(d.models || []); + + /* Max iterations + Sovereign Browser Skill fields. */ + document.getElementById('agent.max_iterations').value = + d.max_iterations != null ? d.max_iterations : 100; + document.getElementById('agent.skill_name').value = + d.skill_name || 'Sovereign Browser Default'; + document.getElementById('agent.skill_description').value = + d.skill_description || 'Default agent skill for sovereign_browser'; + document.getElementById('agent.skill_template').value = + d.skill_template || d.system_prompt || ''; + document.getElementById('agent.skill_requires_tools').value = + d.skill_requires_tools || 'browser, fs, shell'; + + updateRefreshBtn(); +} + +function save(key) { + var el = document.getElementById(key); + var val = el ? el.value : ''; + return sovereignGet('sovereign://agents/set?key=' + encodeURIComponent(key) + + '&value=' + encodeURIComponent(val)) + .then(function(d) { + if (d.error) showStatus('err', d.message); + else showStatus('ok', d.key + ' saved'); + return d; + }) + .catch(function(e) { + showStatus('err', 'Error: ' + e.message); + throw e; + }); +} + +function saveAndRefresh(key) { + save(key).then(function() { + var bu = document.getElementById('agent.llm_base_url'); + var ak = document.getElementById('agent.llm_api_key'); + if (bu && bu.value && bu.value.trim() && ak && ak.value && ak.value.trim()) { + refreshModels(); + } + }).catch(function() {}); +} + +function updateRefreshBtn() { + var bu = document.getElementById('agent.llm_base_url'); + var ak = document.getElementById('agent.llm_api_key'); + var btn = document.getElementById('refresh-models-btn'); + var enabled = bu && bu.value && bu.value.trim() && + ak && ak.value && ak.value.trim(); + btn.disabled = !enabled; +} + +function onProviderChange() { + save('agent.provider').then(function() { window.location.reload(); }); +} + +function saveProviderName() { + var el = document.getElementById('provider_name'); + if (el && el.value && el.value.trim()) { + save('agent.provider_name').then(function() { window.location.reload(); }); + } +} + +function addProvider() { + var el = document.getElementById('new_provider_name'); + if (el && el.value && el.value.trim()) { + sovereignGet('sovereign://agents/set?key=agent.provider_add&value=' + + encodeURIComponent(el.value.trim())) + .then(function(d) { + if (d.error) showStatus('err', d.message); + else { showStatus('ok', 'Provider added'); window.location.reload(); } + }) + .catch(function(e) { showStatus('err', 'Error: ' + e.message); }); + } +} + +function removeProvider() { + var sel = document.getElementById('agent.provider'); + if (sel && sel.value) { + if (!confirm('Remove provider "' + sel.value + '"?')) return; + sovereignGet('sovereign://agents/set?key=agent.provider_remove&value=' + + encodeURIComponent(sel.value)) + .then(function(d) { + if (d.error) showStatus('err', d.message); + else { showStatus('ok', 'Provider removed'); window.location.reload(); } + }) + .catch(function(e) { showStatus('err', 'Error: ' + e.message); }); + } +} + +function onModelChange() { + var sel = document.getElementById('agent.llm_model'); + var txt = document.getElementById('agent.llm_model_text'); + if (sel.value === '__custom__') { + txt.style.display = ''; + txt.value = savedModel; + txt.focus(); + } else { + txt.style.display = 'none'; + savedModel = sel.value; + save('agent.llm_model'); + } +} + +function onModelTextInput() { + var txt = document.getElementById('agent.llm_model_text'); + savedModel = txt.value; +} + +function saveCustomModel() { + var txt = document.getElementById('agent.llm_model_text'); + if (txt && txt.value && txt.value.trim()) { + savedModel = txt.value.trim(); + save('agent.llm_model'); + } +} + +function populateModels(models) { + var sel = document.getElementById('agent.llm_model'); + var txt = document.getElementById('agent.llm_model_text'); + sel.innerHTML = ''; + models = models.slice().sort(function(a, b) { + return a.toLowerCase().localeCompare(b.toLowerCase()); + }); + var found = false; + for (var i = 0; i < models.length; i++) { + var opt = document.createElement('option'); + opt.value = models[i]; + opt.textContent = models[i]; + if (models[i] === savedModel) { opt.selected = true; found = true; } + sel.appendChild(opt); + } + var copt = document.createElement('option'); + copt.value = '__custom__'; + copt.textContent = '(custom...)'; + if (!found) { copt.selected = true; sel.value = '__custom__'; } + sel.appendChild(copt); + if (!found) { + txt.style.display = ''; + txt.value = savedModel; + } else { + txt.style.display = 'none'; + } +} + +function refreshModels() { + var bu = document.getElementById('agent.llm_base_url'); + var ak = document.getElementById('agent.llm_api_key'); + if (!bu || !bu.value || !bu.value.trim()) return; + var btn = document.getElementById('refresh-models-btn'); + btn.disabled = true; + btn.textContent = 'Loading...'; + var url = 'sovereign://agents/models?base_url=' + + encodeURIComponent(bu.value.trim()) + + '&api_key=' + encodeURIComponent(ak ? ak.value : ''); + sovereignGet(url) + .then(function(d) { + if (d && d.error) { + showStatus('err', 'Models: ' + d.error); + } else if (d && d.length) { + populateModels(d); + showStatus('ok', 'Loaded ' + d.length + ' models'); + } else { + showStatus('err', 'No models returned'); + } + }) + .catch(function(e) { showStatus('err', 'Models error: ' + e.message); }) + .then(function() { + btn.textContent = 'Refresh Models'; + updateRefreshBtn(); + }); +} + +/* ── Save as Skill (publish to Nostr as kind 31123) ──────────── */ +function saveAsSkill() { + var name = document.getElementById('agent.skill_name').value.trim(); + var desc = document.getElementById('agent.skill_description').value.trim(); + var content = document.getElementById('agent.skill_template').value.trim(); + var toolsRaw = document.getElementById('agent.skill_requires_tools').value.trim(); + if (!name || !content) { + showStatus('err', 'Name and template are required'); + return; + } + var tools = []; + if (toolsRaw) { + tools = toolsRaw.split(',').map(function(t) { + return t.trim(); + }).filter(function(t) { return t.length > 0; }); + } + /* Save the local fields first, then publish to Nostr. */ + save('agent.skill_name').then(function() { + return save('agent.skill_description'); + }).then(function() { + return save('agent.skill_template'); + }).then(function() { + return save('agent.skill_requires_tools'); + }).then(function() { + var url = 'sovereign://agents/skills/create?name=' + + encodeURIComponent(name) + + '&description=' + encodeURIComponent(desc) + + '&content=' + encodeURIComponent(content) + + '&requires_tools=' + encodeURIComponent(JSON.stringify(tools)); + return sovereignGet(url); + }).then(function(d) { + if (d.error) { + showStatus('err', 'Publish failed: ' + d.message); + } else { + showStatus('ok', 'Skill published to Nostr (d: ' + d.d + ')'); + } + }).catch(function(e) { + showStatus('err', 'Error: ' + e.message); + }); +} + +/* ── Init ───────────────────────────────────────────────────────── */ +sovereignGet('sovereign://agents/config?_=' + Date.now()) + .then(function(d) { applyConfig(d); }) + .catch(function(e) { showStatus('err', 'Failed to load config: ' + e.message); }); + +document.addEventListener('DOMContentLoaded', function() { + var bu = document.getElementById('agent.llm_base_url'); + var ak = document.getElementById('agent.llm_api_key'); + var txt = document.getElementById('agent.llm_model_text'); + if (bu) bu.addEventListener('input', updateRefreshBtn); + if (ak) ak.addEventListener('input', updateRefreshBtn); + if (txt) txt.addEventListener('change', saveCustomModel); +}); + +/* After config loads, auto-refresh models if base URL + API key are set. */ +sovereignGet('sovereign://agents/config?_=' + Date.now()) + .then(function(d) { + if (d.base_url && d.base_url.trim() && d.api_key && d.api_key.trim()) { + refreshModels(); + } + }) + .catch(function() {}); diff --git a/www/bookmarks.css b/www/bookmarks.css new file mode 100644 index 0000000..208c588 --- /dev/null +++ b/www/bookmarks.css @@ -0,0 +1,8 @@ +/* Bookmarks page — sovereign://bookmarks + * Imports the shared sovereign:// base theme, then adds bookmarks-specific + * layout. The base CSS lives in www/sovereign-base.css and is served at + * sovereign://sovereign-base.css. */ +@import url("sovereign://sovereign-base.css"); + +/* Bookmarks-specific tweaks (most layout is already in base .bm/.form/.dir-header). */ +#bm-dir { min-width: 160px; } diff --git a/www/bookmarks.html b/www/bookmarks.html new file mode 100644 index 0000000..eb6e686 --- /dev/null +++ b/www/bookmarks.html @@ -0,0 +1,36 @@ + + + + + + Bookmarks + + + +

Bookmarks

+ + + + + +

Bookmarks

+
+ + + + diff --git a/www/bookmarks.js b/www/bookmarks.js new file mode 100644 index 0000000..9bf3074 --- /dev/null +++ b/www/bookmarks.js @@ -0,0 +1,115 @@ +/* Bookmarks page — sovereign://bookmarks + * + * Fetches the bookmark directory list from sovereign://bookmarks/list + * (JSON) and renders it. Add/delete/create-dir use the existing + * sovereign://bookmarks/{add,delete,createdir,deletedir} endpoints. + */ +function sovereignGet(url) { + return new Promise(function(resolve, reject) { + var x = new XMLHttpRequest(); + x.open('GET', url, true); + x.onreadystatechange = function() { + if (x.readyState !== 4) return; + try { resolve(JSON.parse(x.responseText)); } + catch (e) { reject(e); } + }; + x.onerror = function() { reject(new Error('XHR error')); }; + x.send(); + }); +} + +function esc(s) { + var d = document.createElement('div'); + d.textContent = s == null ? '' : String(s); + return d.innerHTML; +} + +function renderBookmarks(data) { + var haveSigner = !!data.have_signer; + document.getElementById('readonly-note').style.display = + haveSigner ? 'none' : ''; + document.getElementById('add-form').style.display = + haveSigner ? '' : 'none'; + + /* Populate the directory + + + +
+
Tab bar position
+
+
+
+ +
+
Show tab close buttons
+
+
+ +
+
Middle-click to close tab
+
+
+ +
+
Ctrl+Tab to switch tabs
+
+
+ +
+
Allow drag to reorder tabs
+
+
+ +
+
Maximum tabs
+
+
+
+ +

Keyboard Shortcuts

+
+
+
Reset all shortcuts
+
+
+ +

Agent Server

+ +
+
Enable agent server
+
+
+ +
+
Agent server port
+
+
+
+ +
+
Allowed origins
+
+
+
+ +
+
Login timeout (ms)
+
+
+
+ +

Bootstrap Relays

+
+
Relay URLs
+
+
+
+ +

Search

+
+
Search engine
+
+
+
+ +

Security

+ +
+
Developer Tools (Inspector)
+
+
+ +
+
File Access from file://
+
+
+ +
+
Universal Access from file://
+
+
+ +
+
CORS Enforcement
+
+
+ +
+
TLS Certificate Check
+
+
+ +
+ + + + diff --git a/www/settings.js b/www/settings.js new file mode 100644 index 0000000..2c1b380 --- /dev/null +++ b/www/settings.js @@ -0,0 +1,325 @@ +/* Settings page — sovereign://settings + * + * Fetches all settings values from sovereign://settings/config (JSON) on + * page load and populates the DOM. Toggles, saves, and shortcut capture + * use the existing sovereign://settings/set endpoint. + */ +function sovereignGet(url) { + return new Promise(function(resolve, reject) { + var x = new XMLHttpRequest(); + x.open('GET', url, true); + x.onreadystatechange = function() { + if (x.readyState !== 4) return; + try { resolve(JSON.parse(x.responseText)); } + catch (e) { reject(e); } + }; + x.onerror = function() { reject(new Error('XHR error')); }; + x.send(); + }); +} + +function esc(s) { + var d = document.createElement('div'); + d.textContent = s == null ? '' : String(s); + return d.innerHTML; +} + +function showStatus(cls, msg) { + var s = document.getElementById('status'); + s.className = 'status ' + cls + ' show'; + s.textContent = msg; +} + +/* Set a toggle element's on/off class. */ +function setToggleClass(el, on) { + el.classList.remove('on', 'off'); + el.classList.add(on ? 'on' : 'off'); +} + +/* Apply the settings/config JSON to the DOM. */ +function applyConfig(d) { + /* Toggles — each has a data-feature attribute identifying the key. */ + var toggleMap = { + theme_dark: d.theme_dark, + restore_session: d.restore_session, + show_tab_close_buttons: d.show_tab_close_buttons, + middle_click_close: d.middle_click_close, + ctrl_tab_switch: d.ctrl_tab_switch, + tab_drag_reorder: d.tab_drag_reorder, + agent_server_enabled: d.agent_server_enabled, + dev_extras: d.dev_extras, + file_access: d.file_access, + universal_access: d.universal_access + }; + document.querySelectorAll('.toggle[data-feature]').forEach(function(el) { + var key = el.getAttribute('data-feature'); + if (key === 'theme_dark') el.id = 'toggle_theme_dark'; + setToggleClass(el, !!toggleMap[key]); + }); + + /* Text/number inputs. */ + if (d.new_tab_url != null) + document.getElementById('new_tab_url').value = d.new_tab_url; + if (d.max_tabs != null) + document.getElementById('max_tabs').value = d.max_tabs; + if (d.agent_server_port != null) + document.getElementById('agent_server_port').value = d.agent_server_port; + if (d.agent_allowed_origins != null) + document.getElementById('agent_allowed_origins').value = d.agent_allowed_origins; + if (d.agent_login_timeout_ms != null) + document.getElementById('agent_login_timeout_ms').value = d.agent_login_timeout_ms; + if (d.bootstrap_relays != null) + document.getElementById('bootstrap_relays').value = d.bootstrap_relays; + + /* Tab bar position select. */ + if (d.tab_bar_position != null) { + var sel = document.getElementById('tab_bar_position'); + sel.value = d.tab_bar_position; + } + + /* Search engine select. */ + var se = document.getElementById('search_engine'); + se.innerHTML = ''; + (d.search_engines || []).forEach(function(eng) { + var opt = document.createElement('option'); + opt.value = eng.id; + opt.textContent = eng.name; + if (eng.id === d.search_engine) opt.selected = true; + se.appendChild(opt); + }); + + /* Keyboard shortcuts. */ + renderShortcuts(d.shortcuts || []); +} + +/* ── Keyboard shortcut capture ──────────────────────────────────── */ +var capturingAction = null; +var capturingDisplay = null; + +function jsKeyToGdk(e) { + var k = e.key; + if (k.length === 1) { + var c = k.toLowerCase(); + if (c >= 'a' && c <= 'z') return c; + if (c >= '0' && c <= '9') return c; + if (k === ',') return 'comma'; + if (k === ' ') return 'space'; + if (k === '.') return 'period'; + if (k === '/') return 'slash'; + if (k === '-') return 'minus'; + if (k === '=') return 'equal'; + } + var special = { + 'Tab':'Tab','Enter':'Return','Escape':'Escape', + 'Backspace':'BackSpace','Delete':'Delete', + 'Home':'Home','End':'End', + 'PageUp':'Page_Up','PageDown':'Page_Down', + 'ArrowLeft':'Left','ArrowRight':'Right', + 'ArrowUp':'Up','ArrowDown':'Down', + 'F1':'F1','F2':'F2','F3':'F3','F4':'F4','F5':'F5','F6':'F6', + 'F7':'F7','F8':'F8','F9':'F9','F10':'F10','F11':'F11','F12':'F12' + }; + if (special[k]) return special[k]; + return null; +} + +function buildAccel(e) { + var parts = []; + if (e.ctrlKey) parts.push(''); + if (e.altKey) parts.push(''); + if (e.shiftKey) parts.push(''); + if (e.metaKey) parts.push(''); + var gdkKey = jsKeyToGdk(e); + if (!gdkKey) return null; + parts.push(gdkKey); + return parts.join(''); +} + +function displayAccel(accel) { + return accel.replace(//g,'Ctrl+') + .replace(//g,'Alt+') + .replace(//g,'Shift+') + .replace(//g,'Super+'); +} + +function renderShortcuts(shortcuts) { + var c = document.getElementById('shortcuts-section'); + var html = ''; + shortcuts.forEach(function(sc) { + var accel = sc.accel || ''; + html += '
'; + html += '
' + esc(sc.label) + '
'; + html += '
' + + esc(displayAccel(accel)) + ''; + html += ' '; + html += '
'; + html += '
'; + }); + c.innerHTML = html; + bindShortcutButtons(); + checkDuplicates(); +} + +function checkDuplicates() { + var rows = document.querySelectorAll('.shortcut-row'); + var seen = {}; + rows.forEach(function(r) { + r.classList.remove('duplicate'); + var a = r.getAttribute('data-accel'); + if (a) { + if (seen[a]) seen[a].push(r); + else seen[a] = [r]; + } + }); + Object.keys(seen).forEach(function(a) { + if (seen[a].length > 1) { + seen[a].forEach(function(r) { r.classList.add('duplicate'); }); + } + }); +} + +function bindShortcutButtons() { + document.querySelectorAll('.sc-change').forEach(function(btn) { + btn.addEventListener('click', function() { + var action = btn.getAttribute('data-action'); + var disp = document.getElementById('sc_' + action); + if (!disp) return; + if (capturingDisplay) capturingDisplay.classList.remove('capturing'); + capturingAction = action; + capturingDisplay = disp; + disp.setAttribute('data-prev', disp.textContent); + disp.classList.add('capturing'); + disp.textContent = 'Press keys…'; + }); + }); + document.querySelectorAll('.sc-reset').forEach(function(btn) { + btn.addEventListener('click', function() { + resetShortcut(btn.getAttribute('data-action')); + }); + }); + var ra = document.getElementById('sc-reset-all'); + if (ra) ra.onclick = resetAllShortcuts; +} + +function commitShortcut(action, accel) { + sovereignGet('sovereign://settings/set?key=shortcut.' + action + + '&value=' + encodeURIComponent(accel)) + .then(function(d) { + if (d.error) showStatus('err', d.message); + else { + showStatus('ok', action + ' = ' + displayAccel(accel)); + setTimeout(function() { location.reload(); }, 400); + } + }) + .catch(function(e) { showStatus('err', 'Error: ' + e.message); }); +} + +function resetShortcut(action) { + sovereignGet('sovereign://settings/set?key=shortcut.reset&action=' + action) + .then(function(d) { + if (d.error) showStatus('err', d.message); + else { + showStatus('ok', action + ' reset to default'); + setTimeout(function() { location.reload(); }, 400); + } + }) + .catch(function(e) { showStatus('err', 'Error: ' + e.message); }); +} + +function resetAllShortcuts() { + sovereignGet('sovereign://settings/set?key=shortcut.reset_all') + .then(function(d) { + if (d.error) showStatus('err', d.message); + else { + showStatus('ok', 'All shortcuts reset to defaults'); + setTimeout(function() { location.reload(); }, 400); + } + }) + .catch(function(e) { showStatus('err', 'Error: ' + e.message); }); +} + +document.addEventListener('keydown', function(e) { + if (!capturingAction) return; + e.preventDefault(); + e.stopPropagation(); + if (e.key === 'Escape') { + capturingDisplay.classList.remove('capturing'); + capturingDisplay.textContent = + capturingDisplay.getAttribute('data-prev') || ''; + capturingAction = null; + capturingDisplay = null; + showStatus('ok', 'Cancelled'); + return; + } + var bareMod = ['Control','Shift','Alt','Meta'].indexOf(e.key) >= 0; + if (bareMod) return; + var accel = buildAccel(e); + if (!accel) return; + capturingDisplay.classList.remove('capturing'); + capturingDisplay.textContent = displayAccel(accel); + var action = capturingAction; + capturingAction = null; + capturingDisplay = null; + commitShortcut(action, accel); +}, true); + +/* ── Toggle / save actions ──────────────────────────────────────── */ +function toggle(feature) { + if (feature === 'theme_dark') { + var h = document.documentElement; + var isDark = h.classList.contains('dark'); + if (isDark) h.classList.remove('dark'); + else h.classList.add('dark'); + var btn = document.getElementById('toggle_theme_dark'); + if (btn) setToggleClass(btn, !isDark); + var x = new XMLHttpRequest(); + x.open('GET', 'sovereign://settings/set?feature=' + feature, true); + x.send(); + showStatus('ok', 'theme_dark: ' + (isDark ? 'OFF' : 'ON')); + return; + } + var x = new XMLHttpRequest(); + x.open('GET', 'sovereign://settings/set?feature=' + feature, true); + x.onreadystatechange = function() { + if (x.readyState !== 4) return; + try { + var d = JSON.parse(x.responseText); + if (d.error) showStatus('err', d.message); + else { + showStatus('ok', d.key + ': ' + (d.value ? 'ON' : 'OFF')); + setTimeout(function() { location.reload(); }, 300); + } + } catch (e) { showStatus('err', 'Error: ' + e.message); } + }; + x.send(); +} + +function save(key) { + var el = document.getElementById(key); + var val = el ? el.value : ''; + var x = new XMLHttpRequest(); + x.open('GET', 'sovereign://settings/set?key=' + encodeURIComponent(key) + + '&value=' + encodeURIComponent(val), true); + x.onreadystatechange = function() { + if (x.readyState !== 4) return; + try { + var d = JSON.parse(x.responseText); + if (d.error) showStatus('err', d.message); + else { + showStatus('ok', d.key + ' = ' + d.value); + if (d.reload) setTimeout(function() { location.reload(); }, 300); + } + } catch (e) { showStatus('err', 'Error: ' + e.message); } + }; + x.send(); +} + +/* ── Init ───────────────────────────────────────────────────────── */ +sovereignGet('sovereign://settings/config?_=' + Date.now()) + .then(function(d) { applyConfig(d); }) + .catch(function(e) { + showStatus('err', 'Failed to load settings: ' + e.message); + }); diff --git a/www/sovereign-base.css b/www/sovereign-base.css new file mode 100644 index 0000000..f0fb3db --- /dev/null +++ b/www/sovereign-base.css @@ -0,0 +1,146 @@ +/* Shared sovereign:// page CSS (theme variables, base elements, buttons). + * Mirrors sovereign_page_css() in src/nostr_bridge.c so the embedded + * file-based pages stay consistent with the legacy C-string pages. + * Linked by settings.html, bookmarks.html, profile.html, agents/config.html. */ +:root { + --font: monospace; + --primary: #000000; + --secondary: #ffffff; + --accent: #ff0000; + --muted: #dddddd; + --border: var(--muted); + --radius: 5px; + --bg: var(--secondary); + --fg: var(--primary); + --img-gray: 100%; + --img-gray-hover: 20%; + color-scheme: light; +} +html.dark { + --primary: #ffffff; + --secondary: #000000; + --accent: #ff0000; + --muted: #777777; + --border: var(--muted); + color-scheme: dark; +} +* { font-family: var(--font); margin: 0; padding: 0; box-sizing: border-box; } +html { transition: background-color 0.2s ease, color 0.2s ease; } +body { + color: var(--fg); background: var(--bg); + max-width: 800px; margin: 40px auto; padding: 20px; + line-height: 1.5; +} +h1 { + color: var(--primary); text-align: center; + border-bottom: 2px solid var(--primary); + padding-bottom: 8px; margin-bottom: 20px; +} +h2 { + color: var(--primary); margin-top: 30px; margin-bottom: 10px; + border-bottom: 1px solid var(--border); padding-bottom: 4px; +} +h3 { color: var(--primary); margin-top: 20px; margin-bottom: 8px; } +a { color: var(--accent); text-decoration: none; transition: 0.2s; } +a:hover { text-decoration: underline; } +img { filter: grayscale(var(--img-gray)); transition: filter 0.2s; } +img:hover { filter: grayscale(var(--img-gray-hover)); } +.note { color: var(--muted); font-size: 12px; margin: 10px 0; } +.info { color: var(--muted); font-size: 12px; word-break: break-all; } +.missing { color: var(--muted); font-style: italic; } +.info-row { + display: flex; justify-content: space-between; + gap: 20px; padding: 6px 0; + border-bottom: 1px solid var(--border); +} +.info-row span:first-child { font-weight: bold; flex-shrink: 0; } +.setting, .field { + display: flex; justify-content: space-between; + align-items: center; gap: 20px; padding: 12px 0; + border-bottom: 1px solid var(--border); +} +.setting-name { font-weight: bold; } +.setting-desc { color: var(--muted); font-size: 12px; margin-top: 4px; } +.toggle { + position: relative; width: 50px; height: 24px; + background: var(--muted); border-radius: 12px; cursor: pointer; + transition: background 0.2s; flex-shrink: 0; +} +.toggle.on { background: var(--primary); } +.toggle.off { background: var(--muted); } +.toggle::after { + content: ''; position: absolute; top: 2px; left: 2px; + width: 20px; height: 20px; border-radius: 50%; background: var(--secondary); + transition: transform 0.2s; +} +.toggle.on::after { transform: translateX(26px); } +input, select, textarea { + background: var(--bg); color: var(--fg); + border: 1px solid var(--border); border-radius: var(--radius); + padding: 6px 10px; font-family: var(--font); font-size: 13px; + min-width: 200px; +} +input:focus, select:focus, textarea:focus { + border-color: var(--accent); outline: none; +} +.btn, .save-btn { + background: var(--bg); color: var(--primary); + border: 1px solid var(--primary); border-radius: var(--radius); + padding: 6px 16px; cursor: pointer; font-family: var(--font); + font-size: 13px; font-weight: bold; margin-left: 8px; + transition: border-color 0.2s, background 0.2s, color 0.2s; +} +.btn:hover, .save-btn:hover { border-color: var(--accent); } +.btn:active, .save-btn:active { background: var(--accent); color: var(--secondary); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-del { border-color: var(--accent); color: var(--accent); } +.btn-del:hover { background: var(--accent); color: var(--secondary); } +.status { + padding: 8px; margin: 10px 0; border-radius: var(--radius); + display: none; border: 1px solid var(--border); +} +.status.show { display: block; } +.status.ok { border-color: var(--primary); color: var(--primary); } +.status.err { border-color: var(--accent); color: var(--accent); } +.shortcut-display { + display: inline-block; min-width: 120px; + padding: 4px 10px; background: var(--bg); border: 1px solid var(--border); + border-radius: var(--radius); font-family: var(--font); font-size: 13px; + text-align: center; +} +.shortcut-display.capturing { + border-color: var(--accent); + animation: pulse 1s infinite; +} +@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} } +.shortcut-row.duplicate .shortcut-display { border-color: var(--accent); } +.sc-change, .sc-reset { min-width: 70px; } +.qr { text-align: center; margin: 12px 0; } +.qr img { + image-rendering: pixelated; border: 4px solid var(--secondary); + border-radius: 4px; +} +.theme-toggle { + position: fixed; top: 20px; right: 20px; + display: flex; align-items: center; gap: 8px; cursor: pointer; + text-decoration: none; user-select: none; + font-size: 12px; color: var(--muted); user-select: none; + border: 1px solid var(--border); border-radius: var(--radius); + padding: 4px 10px; background: var(--bg); z-index: 10; +} +.theme-toggle:hover { border-color: var(--accent); color: var(--accent); } +.bm { + display: flex; justify-content: space-between; align-items: center; + gap: 20px; padding: 8px 0; border-bottom: 1px solid var(--border); +} +.bm-url { color: var(--accent); text-decoration: none; word-break: break-all; } +.bm-url:hover { text-decoration: underline; } +.bm-title { color: var(--primary); font-weight: bold; } +.bm-meta { color: var(--muted); font-size: 11px; } +.actions { display: flex; gap: 8px; flex-shrink: 0; } +.form { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; } +.empty { color: var(--muted); font-style: italic; padding: 8px 0; } +.dir-header { + display: flex; justify-content: space-between; + align-items: center; gap: 20px; +} diff --git a/www/vendor/marked.min.js b/www/vendor/marked.min.js new file mode 100644 index 0000000..ce72a25 --- /dev/null +++ b/www/vendor/marked.min.js @@ -0,0 +1,6 @@ +/** + * marked v9.0.3 - a markdown parser + * Copyright (c) 2011-2023, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s=/[&<>"']/,r=new RegExp(s.source,"g"),i=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,l=new RegExp(i.source,"g"),o={"&":"&","<":"<",">":">",'"':""","'":"'"},a=e=>o[e];function c(e,t){if(t){if(s.test(e))return e.replace(r,a)}else if(i.test(e))return e.replace(l,a);return e}const h=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;const p=/(^|[^\[])\^/g;function u(e,t){e="string"==typeof e?e:e.source,t=t||"";const n={replace:(t,s)=>(s=(s="object"==typeof s&&"source"in s?s.source:s).replace(p,"$1"),e=e.replace(t,s),n),getRegex:()=>new RegExp(e,t)};return n}function g(e){try{e=encodeURI(e).replace(/%25/g,"%")}catch(e){return null}return e}const k={exec:()=>null};function f(e,t){const n=e.replace(/\|/g,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(/ \|/);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:d(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t){const n=e.match(/^(\s+)(?:```)/);if(null===n)return t;const s=n[1];return t.split("\n").map((e=>{const t=e.match(/^\s+/);if(null===t)return e;const[n]=t;return n.length>=s.length?e.slice(s.length):e})).join("\n")}(e,t[3]||"");return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline._escapes,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(/#$/.test(e)){const t=d(e,"#");this.options.pedantic?e=t.trim():t&&!/ $/.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:t[0]}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){const e=t[0].replace(/^ *>[ \t]?/gm,""),n=this.lexer.state.top;this.lexer.state.top=!0;const s=this.lexer.blockTokens(e);return this.lexer.state.top=n,{type:"blockquote",raw:t[0],tokens:s,text:e}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim();const s=n.length>1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=new RegExp(`^( {0,3}${n})((?:[\t ][^\\n]*)?(?:\\n|$))`);let l="",o="",a=!1;for(;e;){let n=!1;if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;l=t[0],e=e.substring(l.length);let s=t[2].split("\n",1)[0].replace(/^\t+/,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=0;this.options.pedantic?(h=2,o=s.trimStart()):(h=t[2].search(/[^ ]/),h=h>4?1:h,o=s.slice(h),h+=t[1].length);let p=!1;if(!s&&/^ *$/.test(c)&&(l+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),n=new RegExp(`^ {0,${Math.min(3,h-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),r=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:\`\`\`|~~~)`),i=new RegExp(`^ {0,${Math.min(3,h-1)}}#`);for(;e;){const a=e.split("\n",1)[0];if(c=a,this.options.pedantic&&(c=c.replace(/^ {1,4}(?=( {4})*[^ ])/g," ")),r.test(c))break;if(i.test(c))break;if(t.test(c))break;if(n.test(e))break;if(c.search(/[^ ]/)>=h||!c.trim())o+="\n"+c.slice(h);else{if(p)break;if(s.search(/[^ ]/)>=4)break;if(r.test(s))break;if(i.test(s))break;if(n.test(s))break;o+="\n"+c}p||c.trim()||(p=!0),l+=a+"\n",e=e.substring(a.length+1),s=c.slice(h)}}r.loose||(a?r.loose=!0:/\n *\n *$/.test(l)&&(a=!0));let u,g=null;this.options.gfm&&(g=/^\[[ xX]\] /.exec(o),g&&(u="[ ] "!==g[0],o=o.replace(/^\[[ xX]\] +/,""))),r.items.push({type:"list_item",raw:l,task:!!g,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=l}r.items[r.items.length-1].raw=l.trimEnd(),r.items[r.items.length-1].text=o.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>/\n.*\n/.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e$/,"$1").replace(this.rules.inline._escapes,"$1"):"",s=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline._escapes,"$1"):t[3];return{type:"def",tag:e,raw:t[0],href:n,title:s}}}table(e){const t=this.rules.block.table.exec(e);if(t){if(!/[:|]/.test(t[2]))return;const e={type:"table",raw:t[0],header:f(t[1]).map((e=>({text:e,tokens:[]}))),align:t[2].replace(/^\||\| *$/g,"").split("|"),rows:t[3]&&t[3].trim()?t[3].replace(/\n[ \t]*$/,"").split("\n"):[]};if(e.header.length===e.align.length){let t,n,s,r,i=e.align.length;for(t=0;t({text:e,tokens:[]})));for(i=e.header.length,n=0;n/i.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&/^$/.test(e))return;const t=d(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=/^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),/^$/.test(e)?n.slice(1):n.slice(1,-1)),x(t,{href:n?n.replace(this.rules.inline._escapes,"$1"):n,title:s?s.replace(this.rules.inline._escapes,"$1"):s},t[0],this.lexer)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let e=(n[2]||n[1]).replace(/\s+/g," ");if(e=t[e.toLowerCase()],!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return x(n,e,n[0],this.lexer)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrong.lDelim.exec(e);if(!s)return;if(s[3]&&n.match(/[\p{L}\p{N}]/u))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrong.rDelimAst:this.rules.inline.emStrong.rDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+s[0].length-1);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...e].slice(0,n+s.index+i+1).join("");if(Math.min(n,i)%2){const e=t.slice(1,-1);return{type:"em",raw:t,text:e,tokens:this.lexer.inlineTokens(e)}}const a=t.slice(2,-2);return{type:"strong",raw:t,text:a,tokens:this.lexer.inlineTokens(a)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(/\n/g," ");const n=/[^ ]/.test(e),s=/^ /.test(e)&&/ $/.test(e);return n&&s&&(e=e.substring(1,e.length-1)),e=c(e,!0),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=c(t[1]),n="mailto:"+e):(e=c(t[1]),n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=c(t[0]),n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])[0]}while(s!==t[0]);e=c(t[0]),n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){let e;return e=this.lexer.state.inRawBlock?t[0]:c(t[0]),{type:"text",raw:t[0],text:e}}}}const m={newline:/^(?: *(?:\n|$))+/,code:/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,hr:/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,blockquote:/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/,list:/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/,html:"^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))",def:/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/,table:k,lheading:/^(?!bull )((?:.|\n(?!\s*?\n|bull ))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,_paragraph:/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,text:/^[^\n]+/,_label:/(?!\s*\])(?:\\.|[^\[\]\\])+/,_title:/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/};m.def=u(m.def).replace("label",m._label).replace("title",m._title).getRegex(),m.bullet=/(?:[*+-]|\d{1,9}[.)])/,m.listItemStart=u(/^( *)(bull) */).replace("bull",m.bullet).getRegex(),m.list=u(m.list).replace(/bull/g,m.bullet).replace("hr","\\n+(?=\\1?(?:(?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$))").replace("def","\\n+(?="+m.def.source+")").getRegex(),m._tag="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",m._comment=/|$)/,m.html=u(m.html,"i").replace("comment",m._comment).replace("tag",m._tag).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),m.lheading=u(m.lheading).replace(/bull/g,m.bullet).getRegex(),m.paragraph=u(m._paragraph).replace("hr",m.hr).replace("heading"," {0,3}#{1,6} ").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",m._tag).getRegex(),m.blockquote=u(m.blockquote).replace("paragraph",m.paragraph).getRegex(),m.normal={...m},m.gfm={...m.normal,table:"^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)"},m.gfm.table=u(m.gfm.table).replace("hr",m.hr).replace("heading"," {0,3}#{1,6} ").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",m._tag).getRegex(),m.gfm.paragraph=u(m._paragraph).replace("hr",m.hr).replace("heading"," {0,3}#{1,6} ").replace("|lheading","").replace("table",m.gfm.table).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",m._tag).getRegex(),m.pedantic={...m.normal,html:u("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",m._comment).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:k,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:u(m.normal._paragraph).replace("hr",m.hr).replace("heading"," *#{1,6} *[^\n]").replace("lheading",m.lheading).replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").getRegex()};const w={escape:/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,autolink:/^<(scheme:[^\s\x00-\x1f<>]*|email)>/,url:k,tag:"^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^",link:/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/,reflink:/^!?\[(label)\]\[(ref)\]/,nolink:/^!?\[(ref)\](?:\[\])?/,reflinkSearch:"reflink|nolink(?!\\()",emStrong:{lDelim:/^(?:\*+(?:((?!\*)[punct])|[^\s*]))|^_+(?:((?!_)[punct])|([^\s_]))/,rDelimAst:/^[^_*]*?__[^_*]*?\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\*)[punct](\*+)(?=[\s]|$)|[^punct\s](\*+)(?!\*)(?=[punct\s]|$)|(?!\*)[punct\s](\*+)(?=[^punct\s])|[\s](\*+)(?!\*)(?=[punct])|(?!\*)[punct](\*+)(?!\*)(?=[punct])|[^punct\s](\*+)(?=[^punct\s])/,rDelimUnd:/^[^_*]*?\*\*[^_*]*?_[^_*]*?(?=\*\*)|[^_]+(?=[^_])|(?!_)[punct](_+)(?=[\s]|$)|[^punct\s](_+)(?!_)(?=[punct\s]|$)|(?!_)[punct\s](_+)(?=[^punct\s])|[\s](_+)(?!_)(?=[punct])|(?!_)[punct](_+)(?!_)(?=[punct])/},code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,br:/^( {2,}|\\)\n(?!\s*$)/,del:k,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\`^|~"};w.punctuation=u(w.punctuation,"u").replace(/punctuation/g,w._punctuation).getRegex(),w.blockSkip=/\[[^[\]]*?\]\([^\(\)]*?\)|`[^`]*?`|<[^<>]*?>/g,w.anyPunctuation=/\\[punct]/g,w._escapes=/\\([punct])/g,w._comment=u(m._comment).replace("(?:--\x3e|$)","--\x3e").getRegex(),w.emStrong.lDelim=u(w.emStrong.lDelim,"u").replace(/punct/g,w._punctuation).getRegex(),w.emStrong.rDelimAst=u(w.emStrong.rDelimAst,"gu").replace(/punct/g,w._punctuation).getRegex(),w.emStrong.rDelimUnd=u(w.emStrong.rDelimUnd,"gu").replace(/punct/g,w._punctuation).getRegex(),w.anyPunctuation=u(w.anyPunctuation,"gu").replace(/punct/g,w._punctuation).getRegex(),w._escapes=u(w._escapes,"gu").replace(/punct/g,w._punctuation).getRegex(),w._scheme=/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/,w._email=/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/,w.autolink=u(w.autolink).replace("scheme",w._scheme).replace("email",w._email).getRegex(),w._attribute=/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/,w.tag=u(w.tag).replace("comment",w._comment).replace("attribute",w._attribute).getRegex(),w._label=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,w._href=/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/,w._title=/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/,w.link=u(w.link).replace("label",w._label).replace("href",w._href).replace("title",w._title).getRegex(),w.reflink=u(w.reflink).replace("label",w._label).replace("ref",m._label).getRegex(),w.nolink=u(w.nolink).replace("ref",m._label).getRegex(),w.reflinkSearch=u(w.reflinkSearch,"g").replace("reflink",w.reflink).replace("nolink",w.nolink).getRegex(),w.normal={...w},w.pedantic={...w.normal,strong:{start:/^__|\*\*/,middle:/^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/,endAst:/\*\*(?!\*)/g,endUnd:/__(?!_)/g},em:{start:/^_|\*/,middle:/^()\*(?=\S)([\s\S]*?\S)\*(?!\*)|^_(?=\S)([\s\S]*?\S)_(?!_)/,endAst:/\*(?!\*)/g,endUnd:/_(?!_)/g},link:u(/^!?\[(label)\]\((.*?)\)/).replace("label",w._label).getRegex(),reflink:u(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",w._label).getRegex()},w.gfm={...w.normal,escape:u(w.escape).replace("])","~|])").getRegex(),_extended_email:/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/,url:/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])([\s\S]*?[^\s~])\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\t+" ".repeat(n.length)));e;)if(!(this.options.extensions&&this.options.extensions.block&&this.options.extensions.block.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.space(e))e=e.substring(n.raw.length),1===n.raw.length&&t.length>0?t[t.length-1].raw+="\n":t.push(n);else if(n=this.tokenizer.code(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?t.push(n):(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.fences(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.heading(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.hr(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.blockquote(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.list(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.html(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.def(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?this.tokens.links[n.tag]||(this.tokens.links[n.tag]={href:n.href,title:n.title}):(s.raw+="\n"+n.raw,s.text+="\n"+n.raw,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.table(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.lheading(e))e=e.substring(n.raw.length),t.push(n);else{if(r=e,this.options.extensions&&this.options.extensions.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(n=this.tokenizer.paragraph(r)))s=t[t.length-1],i&&"paragraph"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n),i=r.length!==e.length,e=e.substring(n.raw.length);else if(n=this.tokenizer.text(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n,s,r,i,l,o,a=e;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(i=this.tokenizer.rules.inline.reflinkSearch.exec(a));)e.includes(i[0].slice(i[0].lastIndexOf("[")+1,-1))&&(a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(i=this.tokenizer.rules.inline.blockSkip.exec(a));)a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(i=this.tokenizer.rules.inline.anyPunctuation.exec(a));)a=a.slice(0,i.index)+"++"+a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;e;)if(l||(o=""),l=!1,!(this.options.extensions&&this.options.extensions.inline&&this.options.extensions.inline.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.escape(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.tag(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.link(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.reflink(e,this.tokens.links))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.emStrong(e,a,o))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.codespan(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.br(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.del(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.autolink(e))e=e.substring(n.raw.length),t.push(n);else if(this.state.inLink||!(n=this.tokenizer.url(e))){if(r=e,this.options.extensions&&this.options.extensions.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(n=this.tokenizer.inlineText(r))e=e.substring(n.raw.length),"_"!==n.raw.slice(-1)&&(o=n.raw.slice(-1)),l=!0,s=t[t.length-1],s&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}else e=e.substring(n.raw.length),t.push(n);return t}}class y{options;constructor(t){this.options=t||e.defaults}code(e,t,n){const s=(t||"").match(/^\S*/)?.[0];return e=e.replace(/\n$/,"")+"\n",s?'
'+(n?e:c(e,!0))+"
\n":"
"+(n?e:c(e,!0))+"
\n"}blockquote(e){return`
\n${e}
\n`}html(e,t){return e}heading(e,t,n){return`${e}\n`}hr(){return"
\n"}list(e,t,n){const s=t?"ol":"ul";return"<"+s+(t&&1!==n?' start="'+n+'"':"")+">\n"+e+"\n"}listitem(e,t,n){return`
  • ${e}
  • \n`}checkbox(e){return"'}paragraph(e){return`

    ${e}

    \n`}table(e,t){return t&&(t=`${t}`),"\n\n"+e+"\n"+t+"
    \n"}tablerow(e){return`\n${e}\n`}tablecell(e,t){const n=t.header?"th":"td";return(t.align?`<${n} align="${t.align}">`:`<${n}>`)+e+`\n`}strong(e){return`${e}`}em(e){return`${e}`}codespan(e){return`${e}`}br(){return"
    "}del(e){return`${e}`}link(e,t,n){const s=g(e);if(null===s)return n;let r='",r}image(e,t,n){const s=g(e);if(null===s)return n;let r=`${n}"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):"")));continue}case"code":{const e=r;n+=this.renderer.code(e.text,e.lang,!!e.escaped);continue}case"table":{const e=r;let t="",s="";for(let t=0;t0&&"paragraph"===n.tokens[0].type?(n.tokens[0].text=e+" "+n.tokens[0].text,n.tokens[0].tokens&&n.tokens[0].tokens.length>0&&"text"===n.tokens[0].tokens[0].type&&(n.tokens[0].tokens[0].text=e+" "+n.tokens[0].tokens[0].text)):n.tokens.unshift({type:"text",text:e+" "}):o+=e+" "}o+=this.parse(n.tokens,i),l+=this.renderer.listitem(o,r,!!s)}n+=this.renderer.list(l,t,s);continue}case"html":{const e=r;n+=this.renderer.html(e.text,e.block);continue}case"paragraph":{const e=r;n+=this.renderer.paragraph(this.parseInline(e.tokens));continue}case"text":{let i=r,l=i.tokens?this.parseInline(i.tokens):i.text;for(;s+1{n=n.concat(this.walkTokens(e[s],t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new y(this.defaults);for(const n in e.renderer){const s=e.renderer[n],r=n,i=t[r];t[r]=(...e)=>{let n=s.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new b(this.defaults);for(const n in e.tokenizer){const s=e.tokenizer[n],r=n,i=t[r];t[r]=(...e)=>{let n=s.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new T;for(const n in e.hooks){const s=e.hooks[n],r=n,i=t[r];T.passThroughHooks.has(n)?t[r]=e=>{if(this.defaults.async)return Promise.resolve(s.call(t,e)).then((e=>i.call(t,e)));const n=s.call(t,e);return i.call(t,n)}:t[r]=(...e)=>{let n=s.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}#e(e,t){return(n,s)=>{const r={...s},i={...this.defaults,...r};!0===this.defaults.async&&!1===r.async&&(i.silent||console.warn("marked(): The async option was set to true by an extension. The async: false option sent to parse will be ignored."),i.async=!0);const l=this.#t(!!i.silent,!!i.async);if(null==n)return l(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof n)return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));if(i.hooks&&(i.hooks.options=i),i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(n):n).then((t=>e(t,i))).then((e=>i.walkTokens?Promise.all(this.walkTokens(e,i.walkTokens)).then((()=>e)):e)).then((e=>t(e,i))).then((e=>i.hooks?i.hooks.postprocess(e):e)).catch(l);try{i.hooks&&(n=i.hooks.preprocess(n));const s=e(n,i);i.walkTokens&&this.walkTokens(s,i.walkTokens);let r=t(s,i);return i.hooks&&(r=i.hooks.postprocess(r)),r}catch(e){return l(e)}}}#t(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="

    An error occurred:

    "+c(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const S=new R;function A(e,t){return S.parse(e,t)}A.options=A.setOptions=function(e){return S.setOptions(e),A.defaults=S.defaults,n(A.defaults),A},A.getDefaults=t,A.defaults=e.defaults,A.use=function(...e){return S.use(...e),A.defaults=S.defaults,n(A.defaults),A},A.walkTokens=function(e,t){return S.walkTokens(e,t)},A.parseInline=S.parseInline,A.Parser=z,A.parser=z.parse,A.Renderer=y,A.TextRenderer=$,A.Lexer=_,A.lexer=_.lex,A.Tokenizer=b,A.Hooks=T,A.parse=A;const I=A.options,E=A.setOptions,Z=A.use,q=A.walkTokens,L=A.parseInline,D=A,P=z.parse,v=_.lex;e.Hooks=T,e.Lexer=_,e.Marked=R,e.Parser=z,e.Renderer=y,e.TextRenderer=$,e.Tokenizer=b,e.getDefaults=t,e.lexer=v,e.marked=A,e.options=I,e.parse=D,e.parseInline=L,e.parser=P,e.setOptions=E,e.use=Z,e.walkTokens=q})); diff --git a/www/vendor/purify.min.js b/www/vendor/purify.min.js new file mode 100644 index 0000000..73df78d --- /dev/null +++ b/www/vendor/purify.min.js @@ -0,0 +1,3 @@ +/*! @license DOMPurify 3.2.6 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.2.6/LICENSE */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,(function(){"use strict";const{entries:e,setPrototypeOf:t,isFrozen:n,getPrototypeOf:o,getOwnPropertyDescriptor:r}=Object;let{freeze:i,seal:a,create:l}=Object,{apply:c,construct:s}="undefined"!=typeof Reflect&&Reflect;i||(i=function(e){return e}),a||(a=function(e){return e}),c||(c=function(e,t,n){return e.apply(t,n)}),s||(s=function(e,t){return new e(...t)});const u=R(Array.prototype.forEach),m=R(Array.prototype.lastIndexOf),p=R(Array.prototype.pop),f=R(Array.prototype.push),d=R(Array.prototype.splice),h=R(String.prototype.toLowerCase),g=R(String.prototype.toString),T=R(String.prototype.match),y=R(String.prototype.replace),E=R(String.prototype.indexOf),A=R(String.prototype.trim),_=R(Object.prototype.hasOwnProperty),S=R(RegExp.prototype.test),b=(N=TypeError,function(){for(var e=arguments.length,t=new Array(e),n=0;n1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:h;t&&t(e,null);let i=o.length;for(;i--;){let t=o[i];if("string"==typeof t){const e=r(t);e!==t&&(n(o)||(o[i]=e),t=e)}e[t]=!0}return e}function O(e){for(let t=0;t/gm),G=a(/\$\{[\w\W]*/gm),Y=a(/^data-[\-\w.\u00B7-\uFFFF]+$/),j=a(/^aria-[\-\w]+$/),X=a(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),q=a(/^(?:\w+script|data):/i),$=a(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),K=a(/^html$/i),V=a(/^[a-z][.\w]*(-[.\w]+)+$/i);var Z=Object.freeze({__proto__:null,ARIA_ATTR:j,ATTR_WHITESPACE:$,CUSTOM_ELEMENT:V,DATA_ATTR:Y,DOCTYPE_NAME:K,ERB_EXPR:W,IS_ALLOWED_URI:X,IS_SCRIPT_OR_DATA:q,MUSTACHE_EXPR:B,TMPLIT_EXPR:G});const J=1,Q=3,ee=7,te=8,ne=9,oe=function(){return"undefined"==typeof window?null:window};var re=function t(){let n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:oe();const o=e=>t(e);if(o.version="3.2.6",o.removed=[],!n||!n.document||n.document.nodeType!==ne||!n.Element)return o.isSupported=!1,o;let{document:r}=n;const a=r,c=a.currentScript,{DocumentFragment:s,HTMLTemplateElement:N,Node:R,Element:O,NodeFilter:B,NamedNodeMap:W=n.NamedNodeMap||n.MozNamedAttrMap,HTMLFormElement:G,DOMParser:Y,trustedTypes:j}=n,q=O.prototype,$=v(q,"cloneNode"),V=v(q,"remove"),re=v(q,"nextSibling"),ie=v(q,"childNodes"),ae=v(q,"parentNode");if("function"==typeof N){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let le,ce="";const{implementation:se,createNodeIterator:ue,createDocumentFragment:me,getElementsByTagName:pe}=r,{importNode:fe}=a;let de={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]};o.isSupported="function"==typeof e&&"function"==typeof ae&&se&&void 0!==se.createHTMLDocument;const{MUSTACHE_EXPR:he,ERB_EXPR:ge,TMPLIT_EXPR:Te,DATA_ATTR:ye,ARIA_ATTR:Ee,IS_SCRIPT_OR_DATA:Ae,ATTR_WHITESPACE:_e,CUSTOM_ELEMENT:Se}=Z;let{IS_ALLOWED_URI:be}=Z,Ne=null;const Re=w({},[...L,...C,...x,...M,...U]);let we=null;const Oe=w({},[...z,...P,...H,...F]);let De=Object.seal(l(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),ve=null,Le=null,Ce=!0,xe=!0,Ie=!1,Me=!0,ke=!1,Ue=!0,ze=!1,Pe=!1,He=!1,Fe=!1,Be=!1,We=!1,Ge=!0,Ye=!1,je=!0,Xe=!1,qe={},$e=null;const Ke=w({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let Ve=null;const Ze=w({},["audio","video","img","source","image","track"]);let Je=null;const Qe=w({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),et="http://www.w3.org/1998/Math/MathML",tt="http://www.w3.org/2000/svg",nt="http://www.w3.org/1999/xhtml";let ot=nt,rt=!1,it=null;const at=w({},[et,tt,nt],g);let lt=w({},["mi","mo","mn","ms","mtext"]),ct=w({},["annotation-xml"]);const st=w({},["title","style","font","a","script"]);let ut=null;const mt=["application/xhtml+xml","text/html"];let pt=null,ft=null;const dt=r.createElement("form"),ht=function(e){return e instanceof RegExp||e instanceof Function},gt=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!ft||ft!==e){if(e&&"object"==typeof e||(e={}),e=D(e),ut=-1===mt.indexOf(e.PARSER_MEDIA_TYPE)?"text/html":e.PARSER_MEDIA_TYPE,pt="application/xhtml+xml"===ut?g:h,Ne=_(e,"ALLOWED_TAGS")?w({},e.ALLOWED_TAGS,pt):Re,we=_(e,"ALLOWED_ATTR")?w({},e.ALLOWED_ATTR,pt):Oe,it=_(e,"ALLOWED_NAMESPACES")?w({},e.ALLOWED_NAMESPACES,g):at,Je=_(e,"ADD_URI_SAFE_ATTR")?w(D(Qe),e.ADD_URI_SAFE_ATTR,pt):Qe,Ve=_(e,"ADD_DATA_URI_TAGS")?w(D(Ze),e.ADD_DATA_URI_TAGS,pt):Ze,$e=_(e,"FORBID_CONTENTS")?w({},e.FORBID_CONTENTS,pt):Ke,ve=_(e,"FORBID_TAGS")?w({},e.FORBID_TAGS,pt):D({}),Le=_(e,"FORBID_ATTR")?w({},e.FORBID_ATTR,pt):D({}),qe=!!_(e,"USE_PROFILES")&&e.USE_PROFILES,Ce=!1!==e.ALLOW_ARIA_ATTR,xe=!1!==e.ALLOW_DATA_ATTR,Ie=e.ALLOW_UNKNOWN_PROTOCOLS||!1,Me=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,ke=e.SAFE_FOR_TEMPLATES||!1,Ue=!1!==e.SAFE_FOR_XML,ze=e.WHOLE_DOCUMENT||!1,Fe=e.RETURN_DOM||!1,Be=e.RETURN_DOM_FRAGMENT||!1,We=e.RETURN_TRUSTED_TYPE||!1,He=e.FORCE_BODY||!1,Ge=!1!==e.SANITIZE_DOM,Ye=e.SANITIZE_NAMED_PROPS||!1,je=!1!==e.KEEP_CONTENT,Xe=e.IN_PLACE||!1,be=e.ALLOWED_URI_REGEXP||X,ot=e.NAMESPACE||nt,lt=e.MATHML_TEXT_INTEGRATION_POINTS||lt,ct=e.HTML_INTEGRATION_POINTS||ct,De=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&ht(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(De.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&ht(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(De.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(De.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),ke&&(xe=!1),Be&&(Fe=!0),qe&&(Ne=w({},U),we=[],!0===qe.html&&(w(Ne,L),w(we,z)),!0===qe.svg&&(w(Ne,C),w(we,P),w(we,F)),!0===qe.svgFilters&&(w(Ne,x),w(we,P),w(we,F)),!0===qe.mathMl&&(w(Ne,M),w(we,H),w(we,F))),e.ADD_TAGS&&(Ne===Re&&(Ne=D(Ne)),w(Ne,e.ADD_TAGS,pt)),e.ADD_ATTR&&(we===Oe&&(we=D(we)),w(we,e.ADD_ATTR,pt)),e.ADD_URI_SAFE_ATTR&&w(Je,e.ADD_URI_SAFE_ATTR,pt),e.FORBID_CONTENTS&&($e===Ke&&($e=D($e)),w($e,e.FORBID_CONTENTS,pt)),je&&(Ne["#text"]=!0),ze&&w(Ne,["html","head","body"]),Ne.table&&(w(Ne,["tbody"]),delete ve.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw b('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw b('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');le=e.TRUSTED_TYPES_POLICY,ce=le.createHTML("")}else void 0===le&&(le=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}}(j,c)),null!==le&&"string"==typeof ce&&(ce=le.createHTML(""));i&&i(e),ft=e}},Tt=w({},[...C,...x,...I]),yt=w({},[...M,...k]),Et=function(e){f(o.removed,{element:e});try{ae(e).removeChild(e)}catch(t){V(e)}},At=function(e,t){try{f(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){f(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e)if(Fe||Be)try{Et(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},_t=function(e){let t=null,n=null;if(He)e=""+e;else{const t=T(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===ut&&ot===nt&&(e=''+e+"");const o=le?le.createHTML(e):e;if(ot===nt)try{t=(new Y).parseFromString(o,ut)}catch(e){}if(!t||!t.documentElement){t=se.createDocument(ot,"template",null);try{t.documentElement.innerHTML=rt?ce:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),ot===nt?pe.call(t,ze?"html":"body")[0]:ze?t.documentElement:i},St=function(e){return ue.call(e.ownerDocument||e,e,B.SHOW_ELEMENT|B.SHOW_COMMENT|B.SHOW_TEXT|B.SHOW_PROCESSING_INSTRUCTION|B.SHOW_CDATA_SECTION,null)},bt=function(e){return e instanceof G&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof W)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes)},Nt=function(e){return"function"==typeof R&&e instanceof R};function Rt(e,t,n){u(e,(e=>{e.call(o,t,n,ft)}))}const wt=function(e){let t=null;if(Rt(de.beforeSanitizeElements,e,null),bt(e))return Et(e),!0;const n=pt(e.nodeName);if(Rt(de.uponSanitizeElement,e,{tagName:n,allowedTags:Ne}),Ue&&e.hasChildNodes()&&!Nt(e.firstElementChild)&&S(/<[/\w!]/g,e.innerHTML)&&S(/<[/\w!]/g,e.textContent))return Et(e),!0;if(e.nodeType===ee)return Et(e),!0;if(Ue&&e.nodeType===te&&S(/<[/\w]/g,e.data))return Et(e),!0;if(!Ne[n]||ve[n]){if(!ve[n]&&Dt(n)){if(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,n))return!1;if(De.tagNameCheck instanceof Function&&De.tagNameCheck(n))return!1}if(je&&!$e[n]){const t=ae(e)||e.parentNode,n=ie(e)||e.childNodes;if(n&&t){for(let o=n.length-1;o>=0;--o){const r=$(n[o],!0);r.__removalCount=(e.__removalCount||0)+1,t.insertBefore(r,re(e))}}}return Et(e),!0}return e instanceof O&&!function(e){let t=ae(e);t&&t.tagName||(t={namespaceURI:ot,tagName:"template"});const n=h(e.tagName),o=h(t.tagName);return!!it[e.namespaceURI]&&(e.namespaceURI===tt?t.namespaceURI===nt?"svg"===n:t.namespaceURI===et?"svg"===n&&("annotation-xml"===o||lt[o]):Boolean(Tt[n]):e.namespaceURI===et?t.namespaceURI===nt?"math"===n:t.namespaceURI===tt?"math"===n&&ct[o]:Boolean(yt[n]):e.namespaceURI===nt?!(t.namespaceURI===tt&&!ct[o])&&!(t.namespaceURI===et&&!lt[o])&&!yt[n]&&(st[n]||!Tt[n]):!("application/xhtml+xml"!==ut||!it[e.namespaceURI]))}(e)?(Et(e),!0):"noscript"!==n&&"noembed"!==n&&"noframes"!==n||!S(/<\/no(script|embed|frames)/i,e.innerHTML)?(ke&&e.nodeType===Q&&(t=e.textContent,u([he,ge,Te],(e=>{t=y(t,e," ")})),e.textContent!==t&&(f(o.removed,{element:e.cloneNode()}),e.textContent=t)),Rt(de.afterSanitizeElements,e,null),!1):(Et(e),!0)},Ot=function(e,t,n){if(Ge&&("id"===t||"name"===t)&&(n in r||n in dt))return!1;if(xe&&!Le[t]&&S(ye,t));else if(Ce&&S(Ee,t));else if(!we[t]||Le[t]){if(!(Dt(e)&&(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,e)||De.tagNameCheck instanceof Function&&De.tagNameCheck(e))&&(De.attributeNameCheck instanceof RegExp&&S(De.attributeNameCheck,t)||De.attributeNameCheck instanceof Function&&De.attributeNameCheck(t))||"is"===t&&De.allowCustomizedBuiltInElements&&(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,n)||De.tagNameCheck instanceof Function&&De.tagNameCheck(n))))return!1}else if(Je[t]);else if(S(be,y(n,_e,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==E(n,"data:")||!Ve[e]){if(Ie&&!S(Ae,y(n,_e,"")));else if(n)return!1}else;return!0},Dt=function(e){return"annotation-xml"!==e&&T(e,Se)},vt=function(e){Rt(de.beforeSanitizeAttributes,e,null);const{attributes:t}=e;if(!t||bt(e))return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:we,forceKeepAttr:void 0};let r=t.length;for(;r--;){const i=t[r],{name:a,namespaceURI:l,value:c}=i,s=pt(a),m=c;let f="value"===a?m:A(m);if(n.attrName=s,n.attrValue=f,n.keepAttr=!0,n.forceKeepAttr=void 0,Rt(de.uponSanitizeAttribute,e,n),f=n.attrValue,!Ye||"id"!==s&&"name"!==s||(At(a,e),f="user-content-"+f),Ue&&S(/((--!?|])>)|<\/(style|title)/i,f)){At(a,e);continue}if(n.forceKeepAttr)continue;if(!n.keepAttr){At(a,e);continue}if(!Me&&S(/\/>/i,f)){At(a,e);continue}ke&&u([he,ge,Te],(e=>{f=y(f,e," ")}));const d=pt(e.nodeName);if(Ot(d,s,f)){if(le&&"object"==typeof j&&"function"==typeof j.getAttributeType)if(l);else switch(j.getAttributeType(d,s)){case"TrustedHTML":f=le.createHTML(f);break;case"TrustedScriptURL":f=le.createScriptURL(f)}if(f!==m)try{l?e.setAttributeNS(l,a,f):e.setAttribute(a,f),bt(e)?Et(e):p(o.removed)}catch(t){At(a,e)}}else At(a,e)}Rt(de.afterSanitizeAttributes,e,null)},Lt=function e(t){let n=null;const o=St(t);for(Rt(de.beforeSanitizeShadowDOM,t,null);n=o.nextNode();)Rt(de.uponSanitizeShadowNode,n,null),wt(n),vt(n),n.content instanceof s&&e(n.content);Rt(de.afterSanitizeShadowDOM,t,null)};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,i=null,l=null;if(rt=!e,rt&&(e="\x3c!--\x3e"),"string"!=typeof e&&!Nt(e)){if("function"!=typeof e.toString)throw b("toString is not a function");if("string"!=typeof(e=e.toString()))throw b("dirty is not a string, aborting")}if(!o.isSupported)return e;if(Pe||gt(t),o.removed=[],"string"==typeof e&&(Xe=!1),Xe){if(e.nodeName){const t=pt(e.nodeName);if(!Ne[t]||ve[t])throw b("root node is forbidden and cannot be sanitized in-place")}}else if(e instanceof R)n=_t("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),r.nodeType===J&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r);else{if(!Fe&&!ke&&!ze&&-1===e.indexOf("<"))return le&&We?le.createHTML(e):e;if(n=_t(e),!n)return Fe?null:We?ce:""}n&&He&&Et(n.firstChild);const c=St(Xe?e:n);for(;i=c.nextNode();)wt(i),vt(i),i.content instanceof s&&Lt(i.content);if(Xe)return e;if(Fe){if(Be)for(l=me.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(we.shadowroot||we.shadowrootmode)&&(l=fe.call(a,l,!0)),l}let m=ze?n.outerHTML:n.innerHTML;return ze&&Ne["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&S(K,n.ownerDocument.doctype.name)&&(m="\n"+m),ke&&u([he,ge,Te],(e=>{m=y(m,e," ")})),le&&We?le.createHTML(m):m},o.setConfig=function(){gt(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),Pe=!0},o.clearConfig=function(){ft=null,Pe=!1},o.isValidAttribute=function(e,t,n){ft||gt({});const o=pt(e),r=pt(t);return Ot(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&f(de[e],t)},o.removeHook=function(e,t){if(void 0!==t){const n=m(de[e],t);return-1===n?void 0:d(de[e],n,1)[0]}return p(de[e])},o.removeHooks=function(e){de[e]=[]},o.removeAllHooks=function(){de={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},o}();return re})); +//# sourceMappingURL=purify.min.js.map