personal memory agent
0
fork

Configure Feed

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

feat: add App Services framework for background event handling

Add iOS-style background service system that allows apps to register
JavaScript handlers that run globally (even when app is not active).
Services can listen to WebSocket events, update badges dynamically,
show notifications, and run custom logic.

Framework Features:
- AppServices.register() - Register app background services
- AppServices.updateBadge() - Update facet/app badges in real-time
- AppServices.updateSubmenu() - Dynamically update submenu items
- AppServices.notify() - Show browser or in-app toast notifications
- Auto-loading services from app templates with isolated scopes
- XSS protection for notification content

Implementation:
- BaseApp.get_service_template() - Apps return service.html path
- Service templates use Jinja2 (can use url_for() for API calls)
- Services auto-loaded in app.html with IIFE wrapping
- Example service in apps/home demonstrates patterns

Use Cases:
- Real-time badge updates on WebSocket events
- Notifications for important events when not viewing app
- Dynamic UI updates without page refresh
- Background data synchronization

Documentation updated in convey/DESIGN.md with API reference and
usage patterns.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+420
+23
apps/__init__.py
··· 119 119 """ 120 120 return {} 121 121 122 + def get_service_template(self) -> Optional[str]: 123 + """Return path to background service template, or None. 124 + 125 + Background services run globally (even when app is not active) and can: 126 + - Listen to WebSocket events 127 + - Update badge counts dynamically 128 + - Show notifications 129 + - Update submenu items 130 + - Run custom background logic 131 + 132 + Services are loaded once on page load and persist across navigation. 133 + This is similar to iOS background notification handlers. 134 + 135 + Returns: 136 + Template path relative to app's blueprint template folder, or None 137 + Default: None (no background service) 138 + 139 + Example: 140 + def get_service_template(self): 141 + return "service.html" # apps/{name}/templates/service.html 142 + """ 143 + return None 144 + 122 145 123 146 class AppRegistry: 124 147 """Registry for discovering and managing Sunstone apps."""
+3
apps/home/__init__.py
··· 19 19 20 20 def get_workspace_template(self): 21 21 return "workspace.html" 22 + 23 + def get_service_template(self): 24 + return "service.html"
+93
apps/home/templates/service.html
··· 1 + {# Home app background service - example implementation #} 2 + window.AppServices.register('home', { 3 + 4 + initialize() { 5 + console.log('[Home Service] Initialized'); 6 + 7 + // Check if WebSocket events are available 8 + if (!window.appEvents) { 9 + console.warn('[Home Service] WebSocket events not available'); 10 + return; 11 + } 12 + 13 + // Listen to all events for demonstration 14 + window.appEvents.listen('*', this.handleEvent.bind(this)); 15 + }, 16 + 17 + handleEvent(msg) { 18 + // Log all events for debugging 19 + console.log('[Home Service] Event:', msg.tract, msg); 20 + 21 + // Example: Show notification for specific events 22 + if (msg.tract === 'cortex' && msg.event === 'agent_complete') { 23 + this.notifyAgentComplete(msg); 24 + } 25 + 26 + if (msg.tract === 'indexer' && msg.event === 'day_indexed') { 27 + this.notifyDayIndexed(msg); 28 + } 29 + 30 + if (msg.tract === 'task' && msg.event === 'task_complete') { 31 + this.notifyTaskComplete(msg); 32 + } 33 + }, 34 + 35 + notifyAgentComplete(msg) { 36 + // Only notify if not currently viewing the home app 37 + if (this.isCurrentApp()) { 38 + return; // User is already in the app, no need to notify 39 + } 40 + 41 + const agentName = msg.agent || 'Agent'; 42 + window.AppServices.notify( 43 + 'Agent Complete', 44 + `${agentName} finished processing`, 45 + { 46 + icon: '🤖', 47 + tag: 'agent-complete', 48 + duration: 4000 49 + } 50 + ); 51 + }, 52 + 53 + notifyDayIndexed(msg) { 54 + if (this.isCurrentApp()) { 55 + return; 56 + } 57 + 58 + const day = msg.day || 'today'; 59 + window.AppServices.notify( 60 + 'Indexing Complete', 61 + `${day} has been indexed`, 62 + { 63 + icon: '📇', 64 + tag: 'day-indexed', 65 + duration: 3000 66 + } 67 + ); 68 + }, 69 + 70 + notifyTaskComplete(msg) { 71 + if (this.isCurrentApp()) { 72 + return; 73 + } 74 + 75 + const taskName = msg.task || 'Task'; 76 + window.AppServices.notify( 77 + 'Task Complete', 78 + `${taskName} finished`, 79 + { 80 + icon: '✅', 81 + tag: 'task-complete', 82 + duration: 4000 83 + } 84 + ); 85 + }, 86 + 87 + isCurrentApp() { 88 + // Check if home app is currently active 89 + const menuItem = document.querySelector('.menu-item[data-app="home"]'); 90 + return menuItem && menuItem.classList.contains('current'); 91 + } 92 + 93 + });
+50
convey/DESIGN.md
··· 36 36 - `get_app_bar_template()` - Optional bottom bar template (return None to hide app-bar) 37 37 - `get_submenu_items()` - Optional submenu with custom logic per app 38 38 - `get_facet_counts()` - Optional badge counts for facet pills 39 + - `get_service_template()` - Optional background service for global event handling 39 40 40 41 **Submenu Integration** 41 42 - Submenu items can include `data-facet` attribute for facet-based navigation 42 43 - Clicking submenu items with facets uses same selection mechanism as facet pills 43 44 - Non-facet submenu items navigate directly (e.g., date ranges, search scopes) 45 + 46 + --- 47 + 48 + ## App Services (Background Services) 49 + 50 + Apps can register background services that run globally, even when the app is not active. This is similar to iOS background notification handlers. 51 + 52 + **Service File Structure** 53 + ``` 54 + apps/{name}/templates/ 55 + └── service.html # Background service template (JavaScript) 56 + ``` 57 + 58 + **Service Capabilities** 59 + - Listen to WebSocket events (Callosum tracts) 60 + - Update badge counts dynamically 61 + - Show browser or in-app notifications 62 + - Update submenu items in real-time 63 + - Run custom background logic 64 + 65 + **AppServices API** 66 + - `AppServices.register(appName, service)` - Register background service 67 + - `AppServices.updateBadge(appName, facetName, count)` - Update badge counts 68 + - `AppServices.updateSubmenu(appName, items)` - Update submenu items 69 + - `AppServices.notify(title, body, options)` - Show notification 70 + - `AppServices.requestNotificationPermission()` - Request browser notifications 71 + 72 + **Service Registration** 73 + Services are automatically loaded and executed on page load. Each service runs in an isolated function scope and can listen to WebSocket events via `window.appEvents`. 74 + 75 + **Example Service** (apps/home/templates/service.html) 76 + ```javascript 77 + window.AppServices.register('home', { 78 + initialize() { 79 + if (!window.appEvents) return; 80 + window.appEvents.listen('cortex', this.handleCortexEvent.bind(this)); 81 + }, 82 + 83 + handleCortexEvent(msg) { 84 + if (msg.event === 'agent_complete') { 85 + window.AppServices.notify( 86 + 'Agent Complete', 87 + `${msg.agent} finished processing`, 88 + { icon: '🤖', duration: 4000 } 89 + ); 90 + } 91 + } 92 + }); 93 + ``` 44 94 45 95 --- 46 96
+251
convey/templates/app.html
··· 63 63 64 64 <!-- App JavaScript --> 65 65 <script src="{{ url_for('review.static', filename='app.js') }}"></script> 66 + 67 + <!-- App Services Framework --> 68 + <script> 69 + /** 70 + * Global App Services Framework 71 + * 72 + * Allows apps to register background services that run globally (even when 73 + * the app is not active). Services can listen to WebSocket events, update 74 + * badges, show notifications, and run custom logic. 75 + * 76 + * Similar to iOS background notification handlers. 77 + */ 78 + window.AppServices = { 79 + services: {}, 80 + 81 + /** 82 + * Register an app background service 83 + * @param {string} appName - Name of the app 84 + * @param {object} service - Service object with initialize() method 85 + */ 86 + register(appName, service) { 87 + this.services[appName] = service; 88 + if (service.initialize) { 89 + try { 90 + service.initialize(); 91 + } catch (err) { 92 + console.error(`[AppServices] Failed to initialize ${appName} service:`, err); 93 + } 94 + } 95 + }, 96 + 97 + /** 98 + * Update badge count for a facet or app 99 + * @param {string} appName - Name of the app 100 + * @param {string|null} facetName - Facet name, or null for app-level badge 101 + * @param {number} count - Badge count (0 to hide) 102 + */ 103 + updateBadge(appName, facetName, count) { 104 + if (facetName) { 105 + // Update facet pill badge 106 + const facetPill = document.querySelector(`.facet-pill[data-facet="${facetName}"]`); 107 + if (facetPill) { 108 + let badge = facetPill.querySelector('.facet-badge'); 109 + if (!badge) { 110 + badge = document.createElement('span'); 111 + badge.className = 'facet-badge'; 112 + facetPill.appendChild(badge); 113 + } 114 + badge.textContent = count || ''; 115 + badge.style.display = count > 0 ? 'inline-block' : 'none'; 116 + } 117 + 118 + // Update submenu badge for facet 119 + const submenuItem = document.querySelector( 120 + `.menu-item[data-app="${appName}"] .submenu-item[data-facet="${facetName}"]` 121 + ); 122 + if (submenuItem) { 123 + let badge = submenuItem.querySelector('.submenu-badge'); 124 + if (!badge) { 125 + badge = document.createElement('span'); 126 + badge.className = 'submenu-badge'; 127 + submenuItem.appendChild(badge); 128 + } 129 + badge.textContent = count || ''; 130 + badge.style.display = count > 0 ? 'inline-block' : 'none'; 131 + } 132 + } else { 133 + // Update app-level badge in menu 134 + const menuItem = document.querySelector(`.menu-item[data-app="${appName}"]`); 135 + if (menuItem) { 136 + let badge = menuItem.querySelector('.app-badge'); 137 + if (!badge) { 138 + badge = document.createElement('span'); 139 + badge.className = 'app-badge'; 140 + menuItem.querySelector('a').appendChild(badge); 141 + } 142 + badge.textContent = count || ''; 143 + badge.style.display = count > 0 ? 'inline-block' : 'none'; 144 + } 145 + } 146 + }, 147 + 148 + /** 149 + * Update submenu items for an app 150 + * @param {string} appName - Name of the app 151 + * @param {Array} items - Array of {label, path, facet?, count?} objects 152 + */ 153 + updateSubmenu(appName, items) { 154 + const submenu = document.querySelector(`.menu-item[data-app="${appName}"] .submenu`); 155 + if (!submenu) return; 156 + 157 + submenu.innerHTML = items.map(item => ` 158 + <div class="submenu-item" ${item.facet ? `data-facet="${item.facet}"` : ''}> 159 + <a href="${item.path}">${item.label}</a> 160 + ${item.count ? `<span class="submenu-badge">${item.count}</span>` : ''} 161 + </div> 162 + `).join(''); 163 + }, 164 + 165 + /** 166 + * Show a notification (browser or in-app toast) 167 + * @param {string} title - Notification title 168 + * @param {string} body - Notification body 169 + * @param {object} options - {icon, tag, duration} 170 + */ 171 + notify(title, body, options = {}) { 172 + // Try browser notification if permitted 173 + if ('Notification' in window && Notification.permission === 'granted') { 174 + new Notification(title, { 175 + body, 176 + icon: options.icon, 177 + tag: options.tag 178 + }); 179 + } 180 + 181 + // Always show in-app toast 182 + const toast = document.createElement('div'); 183 + toast.className = 'app-notification'; 184 + toast.innerHTML = ` 185 + <div class="notification-icon">${options.icon || '📬'}</div> 186 + <div class="notification-content"> 187 + <strong>${this._escapeHtml(title)}</strong> 188 + <p>${this._escapeHtml(body)}</p> 189 + </div> 190 + `; 191 + document.body.appendChild(toast); 192 + 193 + // Animate in 194 + setTimeout(() => toast.classList.add('show'), 10); 195 + 196 + // Auto-dismiss 197 + const duration = options.duration || 5000; 198 + setTimeout(() => { 199 + toast.classList.remove('show'); 200 + setTimeout(() => toast.remove(), 300); 201 + }, duration); 202 + }, 203 + 204 + /** 205 + * Request browser notification permission 206 + * @returns {Promise<string>} Permission state 207 + */ 208 + async requestNotificationPermission() { 209 + if ('Notification' in window && Notification.permission === 'default') { 210 + return await Notification.requestPermission(); 211 + } 212 + return Notification.permission; 213 + }, 214 + 215 + /** 216 + * Escape HTML to prevent XSS in notifications 217 + * @private 218 + */ 219 + _escapeHtml(text) { 220 + const div = document.createElement('div'); 221 + div.textContent = text; 222 + return div.innerHTML; 223 + } 224 + }; 225 + </script> 226 + 227 + <!-- Load all app background services --> 228 + {% for app_name, app_instance in app_registry.apps.items() %} 229 + {% set service_tpl = app_instance.get_service_template() %} 230 + {% if service_tpl %} 231 + <!-- Background service: {{ app_name }} --> 232 + <script> 233 + (function() { 234 + try { 235 + {% set blueprint = app_instance.get_blueprint() %} 236 + {% with current_app = app_name %} 237 + {% include service_tpl %} 238 + {% endwith %} 239 + } catch (err) { 240 + console.error('[AppServices] Failed to load {{ app_name }} service:', err); 241 + } 242 + })(); 243 + </script> 244 + {% endif %} 245 + {% endfor %} 246 + 247 + <!-- Notification Toast Styles --> 248 + <style> 249 + .app-notification { 250 + position: fixed; 251 + bottom: 80px; 252 + right: 20px; 253 + background: white; 254 + border: 1px solid #e5e7eb; 255 + border-radius: 8px; 256 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 257 + padding: 1rem; 258 + display: flex; 259 + gap: 0.75rem; 260 + align-items: flex-start; 261 + max-width: 360px; 262 + opacity: 0; 263 + transform: translateY(20px); 264 + transition: opacity 0.3s ease, transform 0.3s ease; 265 + z-index: 10000; 266 + } 267 + 268 + .app-notification.show { 269 + opacity: 1; 270 + transform: translateY(0); 271 + } 272 + 273 + .app-notification .notification-icon { 274 + font-size: 1.5rem; 275 + flex-shrink: 0; 276 + } 277 + 278 + .app-notification .notification-content { 279 + flex: 1; 280 + min-width: 0; 281 + } 282 + 283 + .app-notification .notification-content strong { 284 + display: block; 285 + margin-bottom: 0.25rem; 286 + color: #1f2937; 287 + font-size: 0.95rem; 288 + } 289 + 290 + .app-notification .notification-content p { 291 + margin: 0; 292 + color: #6b7280; 293 + font-size: 0.875rem; 294 + line-height: 1.4; 295 + } 296 + 297 + /* Badge styles (if not already defined) */ 298 + .facet-badge, .submenu-badge, .app-badge { 299 + display: inline-block; 300 + background: #ef4444; 301 + color: white; 302 + font-size: 0.75rem; 303 + font-weight: bold; 304 + padding: 0.125rem 0.375rem; 305 + border-radius: 10px; 306 + margin-left: 0.5rem; 307 + min-width: 1.25rem; 308 + text-align: center; 309 + } 310 + 311 + .app-badge { 312 + margin-left: auto; 313 + margin-right: 0; 314 + } 315 + </style> 316 + 66 317 </body> 67 318 </html>