personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'main' of github.com:solpbc/solstone

+663 -31
+11 -1
.env.example
··· 1 1 # solstone Environment Configuration 2 2 # Copy this file to .env and fill in your values 3 + # See docs/INSTALL.md for setup instructions 3 4 4 5 # Required: Path to your journal directory 5 6 JOURNAL_PATH=/path/to/your/journal 6 7 7 - # AI API Keys (at least one required) 8 + # AI API Keys (at least one required for chat and insights) 8 9 GOOGLE_API_KEY=your-google-api-key 9 10 OPENAI_API_KEY=your-openai-api-key 10 11 ANTHROPIC_API_KEY=your-anthropic-api-key 12 + 13 + # HuggingFace token for speaker diarization (required for audio transcription) 14 + # Get token: https://huggingface.co/settings/tokens 15 + # Accept model terms: https://huggingface.co/pyannote/speaker-diarization-3.1 16 + HUGGINGFACE_API_KEY=your-huggingface-token 17 + 18 + # Rev.ai token for imported audio transcription (optional) 19 + # Sign up: https://www.rev.ai/ then get token at https://www.rev.ai/access_token 20 + REVAI_ACCESS_TOKEN=your-revai-token
+3 -25
README.md
··· 34 34 muse (AI agents) 35 35 ``` 36 36 37 - ## Requirements 38 - 39 - - Python 3.10 or later 40 - - AI API keys (Google, OpenAI, or Anthropic) 41 - - Platform-specific dependencies for screen/audio capture 42 - 43 - ## Quick Start 44 - 45 - 1. Install the package: 46 - ```bash 47 - pip install -e . 48 - ``` 49 - 50 - 2. Configure environment (copy `.env.example` to `.env` and add your settings): 51 - ```bash 52 - JOURNAL_PATH=/path/to/your/journal 53 - GOOGLE_API_KEY=your-api-key 54 - ``` 55 - 56 - 3. Start the supervisor (handles capture and processing): 57 - ```bash 58 - think-supervisor 59 - ``` 37 + ## Getting Started 60 38 61 - 4. Launch the web interface: 62 - http://localhost:8000/ 39 + See **[docs/INSTALL.md](docs/INSTALL.md)** for complete setup instructions including system dependencies, API keys, and first-run configuration. 63 40 64 41 ## Documentation 65 42 66 43 | Topic | Document | 67 44 |-------|----------| 45 + | **Installation and setup** | [docs/INSTALL.md](docs/INSTALL.md) | 68 46 | Journal structure and data formats | [docs/JOURNAL.md](docs/JOURNAL.md) | 69 47 | Capture and observation | [docs/OBSERVE.md](docs/OBSERVE.md) | 70 48 | Processing and insights | [docs/THINK.md](docs/THINK.md) |
+67 -3
apps/settings/routes.py
··· 4 4 from __future__ import annotations 5 5 6 6 import json 7 + import re 7 8 from pathlib import Path 8 9 from typing import Any 9 10 10 - from flask import Blueprint, jsonify, render_template, request 11 + from flask import Blueprint, jsonify, request 11 12 12 13 from apps.utils import log_app_action 13 14 from convey import state ··· 64 65 return jsonify({"error": str(e)}), 500 65 66 66 67 68 + @settings_bp.route("/api/facet", methods=["POST"]) 69 + def create_facet() -> Any: 70 + """Create a new facet. 71 + 72 + Accepts JSON with: 73 + title: Display title (required) 74 + 75 + The facet name (slug) is auto-generated from the title. 76 + """ 77 + try: 78 + data = request.get_json() 79 + if not data: 80 + return jsonify({"error": "No data provided"}), 400 81 + 82 + title = data.get("title", "").strip() 83 + if not title: 84 + return jsonify({"error": "Title is required"}), 400 85 + 86 + # Generate slug from title: lowercase, replace spaces/special chars with hyphens 87 + slug = re.sub(r"[^a-z0-9]+", "-", title.lower()) 88 + slug = slug.strip("-") # Remove leading/trailing hyphens 89 + 90 + if not slug: 91 + return ( 92 + jsonify({"error": "Title must contain at least one letter or number"}), 93 + 400, 94 + ) 95 + 96 + # Check for conflicts with existing facets 97 + from think.facets import get_facets 98 + 99 + existing = get_facets() 100 + if slug in existing: 101 + return jsonify({"error": f"Facet '{slug}' already exists"}), 409 102 + 103 + # Create facet directory and config 104 + facet_path = Path(state.journal_root) / "facets" / slug 105 + facet_path.mkdir(parents=True, exist_ok=True) 106 + 107 + config = { 108 + "title": title, 109 + "description": "", 110 + "color": "#667eea", 111 + "emoji": "📦", 112 + } 113 + 114 + config_file = facet_path / "facet.json" 115 + with open(config_file, "w", encoding="utf-8") as f: 116 + json.dump(config, f, indent=2, ensure_ascii=False) 117 + f.write("\n") 118 + 119 + # Log the creation 120 + log_app_action( 121 + app="settings", 122 + facet=slug, 123 + action="facet_create", 124 + params={"title": title}, 125 + ) 126 + 127 + return jsonify({"success": True, "facet": slug, "config": config}), 201 128 + 129 + except Exception as e: 130 + return jsonify({"error": str(e)}), 500 131 + 132 + 67 133 @settings_bp.route("/api/facet/<facet_name>") 68 134 def get_facet_config(facet_name: str) -> Any: 69 135 """Get configuration for a specific facet.""" ··· 141 207 Returns: 142 208 {day, entries, next_cursor} where next_cursor is null if no more days 143 209 """ 144 - import re 145 - 146 210 logs_dir = Path(state.journal_root) / "facets" / facet_name / "logs" 147 211 148 212 if not logs_dir.exists():
+158
convey/static/app.css
··· 1245 1245 body.privacy-blur > *:not(.corner-tags) { 1246 1246 filter: blur(5px); 1247 1247 } 1248 + 1249 + /* ======================================== 1250 + * Facet Add Button 1251 + * Plus button to create new facets 1252 + * ======================================== */ 1253 + .facet-add-pill { 1254 + display: flex; 1255 + align-items: center; 1256 + justify-content: center; 1257 + width: 36px; 1258 + height: 36px; 1259 + border-radius: 50%; 1260 + background: transparent; 1261 + border: 2px dashed #ccc; 1262 + cursor: pointer; 1263 + font-size: 20px; 1264 + color: #999; 1265 + transition: all 0.2s ease; 1266 + flex-shrink: 0; 1267 + } 1268 + 1269 + .facet-add-pill:hover { 1270 + border-color: var(--facet-color, #667eea); 1271 + color: var(--facet-color, #667eea); 1272 + background: var(--facet-bg, rgba(102, 126, 234, 0.1)); 1273 + transform: scale(1.1); 1274 + } 1275 + 1276 + /* Hide add button when facets are disabled */ 1277 + .facet-bar.facets-disabled .facet-add-pill { 1278 + display: none; 1279 + } 1280 + 1281 + /* ======================================== 1282 + * Facet Create Modal 1283 + * Simple modal for creating new facets 1284 + * ======================================== */ 1285 + .facet-create-modal { 1286 + display: none; 1287 + position: fixed; 1288 + z-index: var(--z-modals); 1289 + left: 0; 1290 + top: 0; 1291 + width: 100%; 1292 + height: 100%; 1293 + background: rgba(0, 0, 0, 0.5); 1294 + } 1295 + 1296 + .facet-create-modal.visible { 1297 + display: flex; 1298 + align-items: center; 1299 + justify-content: center; 1300 + } 1301 + 1302 + .facet-create-content { 1303 + background: white; 1304 + padding: 24px; 1305 + border-radius: 12px; 1306 + width: 90%; 1307 + max-width: 400px; 1308 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); 1309 + } 1310 + 1311 + .facet-create-content h3 { 1312 + margin: 0 0 20px 0; 1313 + font-size: 18px; 1314 + font-weight: 600; 1315 + color: #333; 1316 + } 1317 + 1318 + .facet-create-field { 1319 + margin-bottom: 16px; 1320 + } 1321 + 1322 + .facet-create-field label { 1323 + display: block; 1324 + font-size: 14px; 1325 + font-weight: 500; 1326 + color: #555; 1327 + margin-bottom: 6px; 1328 + } 1329 + 1330 + .facet-create-field input { 1331 + width: 100%; 1332 + padding: 10px 12px; 1333 + border: 2px solid #ddd; 1334 + border-radius: 8px; 1335 + font-size: 16px; 1336 + transition: border-color 0.2s; 1337 + } 1338 + 1339 + .facet-create-field input:focus { 1340 + outline: none; 1341 + border-color: var(--facet-color, #667eea); 1342 + } 1343 + 1344 + .facet-create-slug { 1345 + font-size: 13px; 1346 + color: #888; 1347 + margin-top: 6px; 1348 + font-family: monospace; 1349 + } 1350 + 1351 + .facet-create-slug.has-slug::before { 1352 + content: "→ "; 1353 + } 1354 + 1355 + .facet-create-buttons { 1356 + display: flex; 1357 + justify-content: flex-end; 1358 + gap: 12px; 1359 + margin-top: 24px; 1360 + } 1361 + 1362 + .facet-create-buttons button { 1363 + padding: 10px 20px; 1364 + border-radius: 8px; 1365 + font-size: 14px; 1366 + font-weight: 500; 1367 + cursor: pointer; 1368 + transition: all 0.2s; 1369 + } 1370 + 1371 + .facet-create-cancel { 1372 + background: #f5f5f5; 1373 + border: 1px solid #ddd; 1374 + color: #666; 1375 + } 1376 + 1377 + .facet-create-cancel:hover { 1378 + background: #e5e5e5; 1379 + } 1380 + 1381 + .facet-create-submit { 1382 + background: var(--facet-color, #667eea); 1383 + border: none; 1384 + color: white; 1385 + } 1386 + 1387 + .facet-create-submit:hover { 1388 + opacity: 0.9; 1389 + } 1390 + 1391 + .facet-create-submit:disabled { 1392 + background: #ccc; 1393 + cursor: not-allowed; 1394 + } 1395 + 1396 + .facet-create-error { 1397 + color: #dc2626; 1398 + font-size: 13px; 1399 + margin-top: 8px; 1400 + display: none; 1401 + } 1402 + 1403 + .facet-create-error.visible { 1404 + display: block; 1405 + }
+176
convey/static/app.js
··· 151 151 152 152 facetPillsContainer.appendChild(pill); 153 153 }); 154 + 155 + // Add "+" button to create new facets (only if facets enabled) 156 + if (!facetsDisabled) { 157 + const addButton = document.createElement('div'); 158 + addButton.className = 'facet-add-pill'; 159 + addButton.textContent = '+'; 160 + addButton.title = 'Create new facet'; 161 + addButton.onclick = () => openFacetCreateModal(); 162 + facetPillsContainer.appendChild(addButton); 163 + } 154 164 } 155 165 156 166 // Update selection styles without re-rendering ··· 873 883 874 884 // Expose selectFacet globally for notifications and other services 875 885 window.selectFacet = selectFacet; 886 + 887 + // ========== FACET CREATION MODAL ========== 888 + 889 + // Create modal element (once) 890 + function ensureFacetCreateModal() { 891 + if (document.getElementById('facetCreateModal')) return; 892 + 893 + const modal = document.createElement('div'); 894 + modal.id = 'facetCreateModal'; 895 + modal.className = 'facet-create-modal'; 896 + modal.innerHTML = ` 897 + <div class="facet-create-content"> 898 + <h3>Create New Facet</h3> 899 + <div class="facet-create-field"> 900 + <label for="facetCreateTitle">Title</label> 901 + <input type="text" id="facetCreateTitle" placeholder="e.g., Work Projects" autofocus> 902 + <div class="facet-create-slug" id="facetCreateSlug"></div> 903 + <div class="facet-create-error" id="facetCreateError"></div> 904 + </div> 905 + <div class="facet-create-buttons"> 906 + <button class="facet-create-cancel" id="facetCreateCancel">Cancel</button> 907 + <button class="facet-create-submit" id="facetCreateSubmit" disabled>Create</button> 908 + </div> 909 + </div> 910 + `; 911 + document.body.appendChild(modal); 912 + 913 + // Wire up events 914 + const titleInput = document.getElementById('facetCreateTitle'); 915 + const slugDisplay = document.getElementById('facetCreateSlug'); 916 + const submitBtn = document.getElementById('facetCreateSubmit'); 917 + const cancelBtn = document.getElementById('facetCreateCancel'); 918 + const errorDisplay = document.getElementById('facetCreateError'); 919 + 920 + // Live slug generation as user types 921 + titleInput.addEventListener('input', () => { 922 + const title = titleInput.value.trim(); 923 + const slug = titleToSlug(title); 924 + if (slug) { 925 + slugDisplay.textContent = slug; 926 + slugDisplay.classList.add('has-slug'); 927 + } else { 928 + slugDisplay.textContent = ''; 929 + slugDisplay.classList.remove('has-slug'); 930 + } 931 + submitBtn.disabled = !slug; 932 + errorDisplay.classList.remove('visible'); 933 + }); 934 + 935 + // Enter to submit 936 + titleInput.addEventListener('keydown', (e) => { 937 + if (e.key === 'Enter' && !submitBtn.disabled) { 938 + e.preventDefault(); 939 + submitFacetCreate(); 940 + } else if (e.key === 'Escape') { 941 + closeFacetCreateModal(); 942 + } 943 + }); 944 + 945 + // Cancel button 946 + cancelBtn.addEventListener('click', closeFacetCreateModal); 947 + 948 + // Submit button 949 + submitBtn.addEventListener('click', submitFacetCreate); 950 + 951 + // Click outside to close 952 + modal.addEventListener('click', (e) => { 953 + if (e.target === modal) { 954 + closeFacetCreateModal(); 955 + } 956 + }); 957 + } 958 + 959 + // Convert title to slug (kebab-case) 960 + function titleToSlug(title) { 961 + if (!title) return ''; 962 + return title 963 + .toLowerCase() 964 + .replace(/[^a-z0-9]+/g, '-') 965 + .replace(/^-+|-+$/g, ''); 966 + } 967 + 968 + // Open the modal 969 + function openFacetCreateModal() { 970 + ensureFacetCreateModal(); 971 + const modal = document.getElementById('facetCreateModal'); 972 + const titleInput = document.getElementById('facetCreateTitle'); 973 + const slugDisplay = document.getElementById('facetCreateSlug'); 974 + const submitBtn = document.getElementById('facetCreateSubmit'); 975 + const errorDisplay = document.getElementById('facetCreateError'); 976 + 977 + // Reset form 978 + titleInput.value = ''; 979 + slugDisplay.textContent = ''; 980 + slugDisplay.classList.remove('has-slug'); 981 + submitBtn.disabled = true; 982 + errorDisplay.classList.remove('visible'); 983 + 984 + modal.classList.add('visible'); 985 + titleInput.focus(); 986 + } 987 + 988 + // Close the modal 989 + function closeFacetCreateModal() { 990 + const modal = document.getElementById('facetCreateModal'); 991 + if (modal) { 992 + modal.classList.remove('visible'); 993 + } 994 + } 995 + 996 + // Submit facet creation 997 + async function submitFacetCreate() { 998 + const titleInput = document.getElementById('facetCreateTitle'); 999 + const submitBtn = document.getElementById('facetCreateSubmit'); 1000 + const errorDisplay = document.getElementById('facetCreateError'); 1001 + 1002 + const title = titleInput.value.trim(); 1003 + if (!title) return; 1004 + 1005 + submitBtn.disabled = true; 1006 + submitBtn.textContent = 'Creating...'; 1007 + 1008 + try { 1009 + const response = await fetch('/app/settings/api/facet', { 1010 + method: 'POST', 1011 + headers: { 'Content-Type': 'application/json' }, 1012 + body: JSON.stringify({ title }) 1013 + }); 1014 + 1015 + const data = await response.json(); 1016 + 1017 + if (!response.ok) { 1018 + throw new Error(data.error || 'Failed to create facet'); 1019 + } 1020 + 1021 + // Success - close modal, select new facet, navigate to settings 1022 + closeFacetCreateModal(); 1023 + 1024 + // Add new facet to local data 1025 + const newFacet = { 1026 + name: data.facet, 1027 + title: data.config.title, 1028 + color: data.config.color, 1029 + emoji: data.config.emoji, 1030 + muted: false, 1031 + count: 0 1032 + }; 1033 + activeFacets.push(newFacet); 1034 + window.facetsData = activeFacets; 1035 + 1036 + // Re-render facet bar 1037 + renderFacetChooser(); 1038 + 1039 + // Select the new facet 1040 + selectFacet(data.facet); 1041 + 1042 + // Navigate to settings app to customize 1043 + window.location.href = '/app/settings'; 1044 + 1045 + } catch (error) { 1046 + errorDisplay.textContent = error.message; 1047 + errorDisplay.classList.add('visible'); 1048 + submitBtn.disabled = false; 1049 + submitBtn.textContent = 'Create'; 1050 + } 1051 + } 876 1052 877 1053 // Run initialization when DOM is ready 878 1054 if (document.readyState === 'loading') {
+1 -1
docs/CONVEY.md
··· 74 74 - Is automatically discovered and registered by `AppRegistry` 75 75 - Can provide facet-scoped views and background services 76 76 77 - **Available apps:** home, todos, inbox, chat, agents, search, calendar, entities, news, stats, tokens, settings, import, live, dev 77 + Browse `/apps/` to see available apps. 78 78 79 79 ### Core Routes 80 80
+246
docs/INSTALL.md
··· 1 + # Installation Guide 2 + 3 + Complete setup instructions for solstone on Linux and macOS. 4 + 5 + ## Prerequisites 6 + 7 + - Python 3.10 or later 8 + - Git 9 + - ffmpeg (for audio processing) 10 + 11 + ### Linux (Fedora/RHEL) 12 + 13 + ```bash 14 + sudo dnf install python3 python3-pip git ffmpeg pipewire gstreamer1-plugins-base 15 + ``` 16 + 17 + ### Linux (Ubuntu/Debian) 18 + 19 + ```bash 20 + sudo apt install python3 python3-pip git ffmpeg pipewire gstreamer1.0-tools 21 + ``` 22 + 23 + ### Linux (Arch) 24 + 25 + ```bash 26 + sudo pacman -S python python-pip git ffmpeg pipewire gstreamer 27 + ``` 28 + 29 + ### macOS 30 + 31 + ```bash 32 + xcode-select --install # Command line tools 33 + brew install python git ffmpeg 34 + ``` 35 + 36 + --- 37 + 38 + ## Installation 39 + 40 + 1. Clone and install: 41 + 42 + ```bash 43 + git clone https://github.com/solpbc/solstone.git 44 + cd solstone 45 + pip install -e . 46 + ``` 47 + 48 + 2. Copy the environment template: 49 + 50 + ```bash 51 + cp .env.example .env 52 + ``` 53 + 54 + 3. Create your journal directory: 55 + 56 + ```bash 57 + mkdir -p ~/Documents/journal 58 + ``` 59 + 60 + 4. Edit `.env` and set your journal path: 61 + 62 + ``` 63 + JOURNAL_PATH=~/Documents/journal 64 + ``` 65 + 66 + --- 67 + 68 + ## API Keys 69 + 70 + solstone requires API keys for AI services. Configure these in your `.env` file. 71 + 72 + ### Google AI (Gemini) - Recommended 73 + 74 + Primary backend for transcription, vision analysis, and insights. 75 + 76 + 1. Go to [Google AI Studio](https://aistudio.google.com/apikey) 77 + 2. Sign in with your Google account 78 + 3. Click "Create API Key" 79 + 4. Copy the key to `.env`: 80 + 81 + ``` 82 + GOOGLE_API_KEY=your-key-here 83 + ``` 84 + 85 + ### OpenAI (Optional) 86 + 87 + Alternative backend for chat and agents. 88 + 89 + 1. Go to [OpenAI API Keys](https://platform.openai.com/api-keys) 90 + 2. Sign in or create an account 91 + 3. Click "Create new secret key" 92 + 4. Copy the key to `.env`: 93 + 94 + ``` 95 + OPENAI_API_KEY=your-key-here 96 + ``` 97 + 98 + ### Anthropic (Optional) 99 + 100 + Alternative backend for chat and agents. 101 + 102 + 1. Go to [Anthropic Console](https://console.anthropic.com/settings/keys) 103 + 2. Sign in or create an account 104 + 3. Click "Create Key" 105 + 4. Copy the key to `.env`: 106 + 107 + ``` 108 + ANTHROPIC_API_KEY=your-key-here 109 + ``` 110 + 111 + --- 112 + 113 + ## Speaker Diarization (HuggingFace) 114 + 115 + Required for audio transcription with speaker identification. 116 + 117 + 1. Create a [HuggingFace account](https://huggingface.co/join) 118 + 119 + 2. Go to [Settings > Access Tokens](https://huggingface.co/settings/tokens) 120 + 121 + 3. Click "Create new token" with read access 122 + 123 + 4. **Accept the model license** - visit [pyannote/speaker-diarization-3.1](https://huggingface.co/pyannote/speaker-diarization-3.1) and click "Agree and access repository" 124 + 125 + 5. Add the token to `.env`: 126 + 127 + ``` 128 + HUGGINGFACE_API_KEY=your-token-here 129 + ``` 130 + 131 + **Note:** The first transcription will download ~1GB of model files. 132 + 133 + --- 134 + 135 + ## Rev.ai for Imports (Optional) 136 + 137 + For transcribing imported audio files (meetings, voice memos, etc.). 138 + 139 + 1. Sign up at [Rev.ai](https://www.rev.ai/) 140 + 141 + 2. After account creation, go to [Access Token](https://www.rev.ai/access_token) 142 + 143 + 3. Click "Generate New Access Token" 144 + 145 + 4. Add the token to `.env`: 146 + 147 + ``` 148 + REVAI_ACCESS_TOKEN=your-token-here 149 + ``` 150 + 151 + --- 152 + 153 + ## First Run 154 + 155 + ### Start the Supervisor 156 + 157 + The supervisor manages all background services (capture, processing): 158 + 159 + ```bash 160 + think-supervisor 161 + ``` 162 + 163 + This starts: 164 + - **Observer** - Screen and audio capture 165 + - **Sense** - File detection and processing dispatch 166 + - **Callosum** - Message bus for inter-service communication 167 + 168 + ### Verify Services 169 + 170 + In another terminal, check that services are running: 171 + 172 + ```bash 173 + pgrep -af "observer|observe-sense|think-supervisor" 174 + ``` 175 + 176 + You should see three processes. 177 + 178 + --- 179 + 180 + ## Web Interface 181 + 182 + ### Set a Password 183 + 184 + Before accessing the web interface, you must configure a password. 185 + 186 + Create the config file: 187 + 188 + ```bash 189 + mkdir -p $JOURNAL_PATH/config 190 + cat > $JOURNAL_PATH/config/journal.json << 'EOF' 191 + { 192 + "convey": { 193 + "password": "your-password-here" 194 + } 195 + } 196 + EOF 197 + ``` 198 + 199 + Replace `your-password-here` with a secure password. 200 + 201 + ### Access the Interface 202 + 203 + Open http://localhost:8000/ in your browser and log in with your password. 204 + 205 + ### Configure Your Identity 206 + 207 + After logging in: 208 + 209 + 1. Click the **Settings** app in the left menu (gear icon) 210 + 2. Fill in your identity information: 211 + - **Full Name** - Your legal name 212 + - **Preferred Name** - How you want to be addressed 213 + - **Pronouns** - Select from dropdown 214 + - **Timezone** - Auto-detected, adjust if needed 215 + 216 + This helps the system identify you in transcripts and personalize AI responses. 217 + 218 + --- 219 + 220 + ## Health Check 221 + 222 + Verify everything is working: 223 + 224 + ```bash 225 + # Check services are running 226 + pgrep -af "observer|observe-sense|think-supervisor" 227 + 228 + # Check Callosum socket exists 229 + ls -la $JOURNAL_PATH/health/callosum.sock 230 + 231 + # View service logs 232 + tail -f $JOURNAL_PATH/health/*.log 233 + ``` 234 + 235 + See [DOCTOR.md](DOCTOR.md) for troubleshooting. 236 + 237 + --- 238 + 239 + ## Next Steps 240 + 241 + - Create your first facet (project/context) in the web interface 242 + - Start capturing - the observer runs automatically 243 + - Review captured content in the Calendar and Transcripts apps 244 + - Chat with the AI about your journal content 245 + 246 + For development setup, see [AGENTS.md](../AGENTS.md).
+1 -1
muse/agents.py
··· 315 315 continue 316 316 317 317 # Extract backend to route to correct module 318 - backend = config.get("backend", "openai") 318 + backend = config.get("backend", "google") 319 319 320 320 # Set OpenAI key if needed 321 321 if backend == "openai":