personal memory agent
0
fork

Configure Feed

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

refactor: simplify app system to convention over configuration

Replace class-based app system with convention-based discovery for simpler
app development with zero boilerplate.

Before (class-based):
- apps/home/__init__.py - HomeApp class inheriting from BaseApp
- apps/home/templates/workspace.html - nested in templates/ folder
- Manual path overrides needed to avoid collisions
- Confusing template resolution (relative vs absolute paths)

After (convention-based):
- apps/home/app.json - {"icon": "🏠", "label": "Home"}
- apps/home/workspace.html - directly in app folder
- apps/home/routes.py - Flask blueprint (auto-discovered)
- apps/home/service.html - optional background service
- apps/home/hooks.py - optional dynamic logic (future)

Changes:
- Refactor apps/__init__.py to use dataclass App instead of BaseApp ABC
- Discovery now looks for routes.py + workspace.html (required)
- Optional files auto-detected: service.html, app_bar.html, hooks.py
- Template paths resolve automatically relative to app folder
- Metadata (icon, label) loaded from app.json with sensible defaults
- Blueprint auto-discovered by finding *_bp variable in routes.py

- Update convey/__init__.py to add apps/ to Jinja template search path
- Remove apps/home/__init__.py and apps/dev/__init__.py classes
- Move templates from apps/{name}/templates/ to apps/{name}/ directly
- Add app.json files for metadata
- Remove template_folder from blueprints (no longer needed)

Benefits:
- No Python classes, inheritance, or method overriding needed
- No template path collisions (automatic resolution)
- Creating new apps is trivial (3 files: routes.py, workspace.html, app.json)
- Clear separation: routes.py for logic, templates for UI, app.json for metadata
- Optional hooks.py for dynamic logic when needed

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

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

+217 -163
+178 -116
apps/__init__.py
··· 1 1 """App plugin system for Sunstone. 2 2 3 - Apps are self-contained modules that can extend any part of the system: 4 - - routes.py: Convey web routes and handlers 5 - - templates/: Jinja2 templates for web views 6 - - agents/: Muse agent prompts (future) 7 - - topics/: Think topic templates (future) 8 - - tasks/: Background task definitions (future) 3 + Convention-based app discovery with minimal configuration: 4 + 5 + Directory Structure: 6 + apps/myapp/ 7 + routes.py # Required: Flask blueprint 8 + workspace.html # Required: Main template 9 + service.html # Optional: Background service 10 + app_bar.html # Optional: Bottom bar 11 + app.json # Optional: Metadata overrides 12 + hooks.py # Optional: Dynamic logic 13 + 14 + app.json format (all optional): 15 + { 16 + "icon": "🏠", 17 + "label": "Custom Label" 18 + } 19 + 20 + hooks.py format (all functions optional): 21 + def get_submenu_items(facets, selected_facet): 22 + return [{"label": "...", "path": "...", "count": 0, "facet": "..."}] 9 23 10 - Each app provides a Flask blueprint for web routes and metadata for 11 - navigation. Apps are automatically discovered and registered. 24 + def get_facet_counts(facets, selected_facet): 25 + return {"facet_name": count} 26 + 27 + Apps are automatically discovered and registered. 12 28 """ 13 29 14 30 from __future__ import annotations 15 31 16 32 import importlib 33 + import json 17 34 import logging 18 - from abc import ABC, abstractmethod 35 + from dataclasses import dataclass, field 19 36 from pathlib import Path 20 - from typing import Optional 37 + from typing import Any, Callable, Optional 21 38 22 39 from flask import Blueprint 23 40 24 41 logger = logging.getLogger(__name__) 25 42 26 43 27 - class BaseApp(ABC): 28 - """Base class for all Sunstone apps. 29 - 30 - Apps must inherit from this class and implement the required methods. 31 - All apps automatically get facet integration (facet pills, selected_facet 32 - context, and facet cookie handling). 33 - """ 44 + @dataclass 45 + class App: 46 + """Convention-based app configuration.""" 34 47 35 - # Required metadata - subclasses must set these 36 48 name: str 37 49 icon: str 38 50 label: str 51 + blueprint: Blueprint 39 52 40 - @abstractmethod 41 - def get_blueprint(self) -> Blueprint: 42 - """Return Flask Blueprint with app routes. 53 + # Template paths (relative to Flask template root) 54 + workspace_template: str 55 + app_bar_template: Optional[str] = None 56 + service_template: Optional[str] = None 43 57 44 - The blueprint should handle all routing for the app. Typically, 45 - it will have at least one route that renders app.html with 46 - app=self.name. 58 + # Dynamic hooks (optional) 59 + hooks: dict[str, Callable] = field(default_factory=dict) 47 60 48 - Returns: 49 - Flask Blueprint instance with routes registered 50 - """ 51 - pass 61 + def get_blueprint(self) -> Blueprint: 62 + """Return Flask Blueprint with app routes.""" 63 + return self.blueprint 52 64 53 65 def get_workspace_template(self) -> str: 54 - """Return path to workspace template. 55 - 56 - This template is included in the main content area and should 57 - contain the primary UI for the app. 58 - 59 - Returns: 60 - Template path relative to Flask template directory 61 - Default: apps/{name}/templates/workspace.html 62 - """ 63 - return f"apps/{self.name}/templates/workspace.html" 66 + """Return path to workspace template.""" 67 + return self.workspace_template 64 68 65 69 def get_app_bar_template(self) -> Optional[str]: 66 - """Return path to custom app-bar template, or None for empty. 70 + """Return path to custom app-bar template, or None.""" 71 + return self.app_bar_template 67 72 68 - The app-bar is the bottom fixed bar where apps can place actions, 69 - inputs, controls, etc. Return None to leave it empty. 70 - 71 - Returns: 72 - Template path or None for no app-bar 73 - Default: None (empty app-bar) 74 - """ 75 - return None 73 + def get_service_template(self) -> Optional[str]: 74 + """Return path to background service template, or None.""" 75 + return self.service_template 76 76 77 77 def get_submenu_items( 78 78 self, facets: list[dict], selected_facet: Optional[str] = None 79 79 ) -> list[dict]: 80 80 """Return submenu items for the menu-bar. 81 81 82 - Fully custom implementation per app. Apps can create submenu items 83 - based on facets, dates, static lists, or any other pattern. 82 + Calls hooks.get_submenu_items() if defined, otherwise returns empty list. 84 83 85 84 Args: 86 85 facets: List of active facet dicts with name, title, color, emoji ··· 92 91 - path: URL path 93 92 - count: Optional badge count (int) 94 93 - facet: Optional facet name for data-facet attribute 95 - 96 - Example: 97 - [ 98 - {"label": "Personal", "path": "/app/todos", "facet": "personal", "count": 7}, 99 - {"label": "Work", "path": "/app/todos", "facet": "work", "count": 5}, 100 - ] 101 94 """ 95 + hook = self.hooks.get("get_submenu_items") 96 + if hook: 97 + return hook(facets, selected_facet) 102 98 return [] 103 99 104 100 def get_facet_counts( 105 101 self, facets: list[dict], selected_facet: Optional[str] = None 106 102 ) -> dict[str, int]: 107 - """Optional: Return badge counts for facet pills. 103 + """Return badge counts for facet pills. 108 104 109 - Only implement if app wants to show counts on facet pills. 110 - Leave unimplemented or return empty dict to show no counts. 105 + Calls hooks.get_facet_counts() if defined, otherwise returns empty dict. 111 106 112 107 Args: 113 108 facets: List of active facet dicts ··· 117 112 Dict mapping facet name to count, e.g.: 118 113 {"work": 5, "personal": 3, "acme": 12} 119 114 """ 115 + hook = self.hooks.get("get_facet_counts") 116 + if hook: 117 + return hook(facets, selected_facet) 120 118 return {} 121 119 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 - 145 120 146 121 class AppRegistry: 147 122 """Registry for discovering and managing Sunstone apps.""" 148 123 149 124 def __init__(self): 150 - self.apps: dict[str, BaseApp] = {} 125 + self.apps: dict[str, App] = {} 151 126 152 127 def discover(self) -> None: 153 - """Auto-discover apps in apps/ directory. 128 + """Auto-discover apps using convention over configuration. 154 129 155 - Scans the apps/ directory for subdirectories and attempts to import 156 - and instantiate an App class from each one. Apps must follow the 157 - naming convention: {name}App (e.g., TodosApp, InboxApp, HomeApp). 158 - 159 - The app module should be at apps/{name}/__init__.py and contain 160 - a class named {Name}App that inherits from BaseApp. 130 + For each directory in apps/: 131 + 1. Load app.json if present (for icon, label overrides) 132 + 2. Import routes.py and get blueprint 133 + 3. Check for workspace.html (required) 134 + 4. Check for service.html, app_bar.html (optional) 135 + 5. Import hooks.py if present (for dynamic logic) 161 136 """ 162 137 apps_dir = Path(__file__).parent 163 138 ··· 168 143 169 144 app_name = app_path.name 170 145 171 - # Skip if __init__.py doesn't exist 172 - if not (app_path / "__init__.py").exists(): 146 + # Skip if routes.py doesn't exist (required) 147 + if not (app_path / "routes.py").exists(): 148 + logger.debug(f"Skipping {app_name}/ - no routes.py found") 149 + continue 150 + 151 + # Skip if workspace.html doesn't exist (required) 152 + if not (app_path / "workspace.html").exists(): 153 + logger.debug(f"Skipping {app_name}/ - no workspace.html found") 173 154 continue 174 155 175 156 try: 176 - # Import app module 177 - module = importlib.import_module(f"apps.{app_name}") 157 + app = self._load_app(app_name, app_path) 158 + self.apps[app_name] = app 159 + logger.info(f"Discovered app: {app_name}") 160 + except Exception as e: 161 + logger.error(f"Failed to load app {app_name}: {e}", exc_info=True) 178 162 179 - # Find App class (e.g., TodosApp, InboxApp, HomeApp) 180 - # Convert kebab-case or snake_case to PascalCase 181 - app_class_name = f"{app_name.title().replace('_', '')}App" 163 + def _load_app(self, app_name: str, app_path: Path) -> App: 164 + """Load a single app from its directory. 182 165 183 - if hasattr(module, app_class_name): 184 - app_class = getattr(module, app_class_name) 185 - app_instance = app_class() 166 + Args: 167 + app_name: Name of the app (directory name) 168 + app_path: Path to app directory 169 + 170 + Returns: 171 + App instance 172 + 173 + Raises: 174 + Exception: If app cannot be loaded 175 + """ 176 + # Load metadata from app.json (optional) 177 + metadata = self._load_metadata(app_path) 178 + 179 + # Get icon and label (with defaults) 180 + icon = metadata.get("icon", "📦") 181 + label = metadata.get("label", app_name.replace("_", " ").title()) 182 + 183 + # Import routes module and get blueprint 184 + routes_module = importlib.import_module(f"apps.{app_name}.routes") 185 + 186 + # Find blueprint - look for *_bp attribute 187 + blueprint = None 188 + for attr_name in dir(routes_module): 189 + if attr_name.endswith("_bp"): 190 + blueprint = getattr(routes_module, attr_name) 191 + if isinstance(blueprint, Blueprint): 192 + break 193 + 194 + if not blueprint: 195 + raise ValueError(f"No blueprint found in apps.{app_name}.routes") 196 + 197 + # Resolve template paths (relative to apps/ directory since that's in the loader) 198 + workspace_template = f"{app_name}/workspace.html" 199 + 200 + service_template = None 201 + if (app_path / "service.html").exists(): 202 + service_template = f"{app_name}/service.html" 203 + 204 + app_bar_template = None 205 + if (app_path / "app_bar.html").exists(): 206 + app_bar_template = f"{app_name}/app_bar.html" 207 + 208 + # Load hooks (optional) 209 + hooks = self._load_hooks(app_name, app_path) 210 + 211 + return App( 212 + name=app_name, 213 + icon=icon, 214 + label=label, 215 + blueprint=blueprint, 216 + workspace_template=workspace_template, 217 + app_bar_template=app_bar_template, 218 + service_template=service_template, 219 + hooks=hooks, 220 + ) 186 221 187 - # Validate it's a BaseApp subclass 188 - if not isinstance(app_instance, BaseApp): 189 - logger.warning( 190 - f"App {app_class_name} is not a BaseApp subclass, skipping" 191 - ) 192 - continue 222 + def _load_metadata(self, app_path: Path) -> dict[str, Any]: 223 + """Load app.json metadata file if it exists. 193 224 194 - # Register the app 195 - self.apps[app_instance.name] = app_instance 196 - logger.info(f"Discovered app: {app_instance.name}") 197 - else: 198 - logger.debug( 199 - f"App directory {app_name}/ missing {app_class_name} class, skipping" 200 - ) 225 + Args: 226 + app_path: Path to app directory 201 227 228 + Returns: 229 + Dict with metadata, or empty dict if no app.json 230 + """ 231 + metadata_file = app_path / "app.json" 232 + if metadata_file.exists(): 233 + try: 234 + with open(metadata_file) as f: 235 + return json.load(f) 202 236 except Exception as e: 203 - logger.error(f"Failed to load app {app_name}: {e}", exc_info=True) 237 + logger.warning(f"Failed to load {metadata_file}: {e}") 238 + return {} 239 + 240 + def _load_hooks(self, app_name: str, app_path: Path) -> dict[str, Callable]: 241 + """Load hooks.py module if it exists. 242 + 243 + Args: 244 + app_name: Name of the app 245 + app_path: Path to app directory 246 + 247 + Returns: 248 + Dict mapping hook name to callable 249 + """ 250 + hooks_file = app_path / "hooks.py" 251 + if not hooks_file.exists(): 252 + return {} 253 + 254 + try: 255 + hooks_module = importlib.import_module(f"apps.{app_name}.hooks") 256 + hooks = {} 257 + 258 + # Look for known hook functions 259 + for hook_name in ["get_submenu_items", "get_facet_counts"]: 260 + if hasattr(hooks_module, hook_name): 261 + hooks[hook_name] = getattr(hooks_module, hook_name) 262 + 263 + return hooks 264 + except Exception as e: 265 + logger.warning(f"Failed to load hooks for {app_name}: {e}") 266 + return {} 204 267 205 268 def register_blueprints(self, flask_app) -> None: 206 269 """Register all app blueprints with Flask. ··· 210 273 """ 211 274 for app in self.apps.values(): 212 275 try: 213 - blueprint = app.get_blueprint() 214 - flask_app.register_blueprint(blueprint) 215 - logger.info(f"Registered blueprint: {blueprint.name}") 276 + flask_app.register_blueprint(app.blueprint) 277 + logger.info(f"Registered blueprint: {app.blueprint.name}") 216 278 except Exception as e: 217 279 logger.error( 218 280 f"Failed to register blueprint for app {app.name}: {e}",
-21
apps/dev/__init__.py
··· 1 - """Dev app - testing and development tools.""" 2 - 3 - from __future__ import annotations 4 - 5 - from apps import BaseApp 6 - 7 - 8 - class DevApp(BaseApp): 9 - """Dev app for testing notification system and other features.""" 10 - 11 - name = "dev" 12 - icon = "🛠️" 13 - label = "Dev Tools" 14 - 15 - def get_blueprint(self): 16 - from .routes import dev_bp 17 - 18 - return dev_bp 19 - 20 - def get_workspace_template(self): 21 - return "workspace.html"
+4
apps/dev/app.json
··· 1 + { 2 + "icon": "🛠️", 3 + "label": "Dev Tools" 4 + }
-1
apps/dev/routes.py
··· 7 7 dev_bp = Blueprint( 8 8 "dev_app", 9 9 __name__, 10 - template_folder="templates", 11 10 url_prefix="/app/dev", 12 11 ) 13 12
+20
apps/dev/templates/workspace.html apps/dev/workspace.html
··· 158 158 <button class="dev-button" onclick="testNonDismissible()">Non-Dismissible</button> 159 159 <button class="dev-button" onclick="testLongMessage()">Long Message</button> 160 160 <button class="dev-button" onclick="testNoAction()">No Click Action</button> 161 + <button class="dev-button" onclick="testWithFacet()">With Facet (work)</button> 161 162 </div> 162 163 </div> 163 164 ··· 185 186 <div class="dev-form-row"> 186 187 <label>Action URL</label> 187 188 <input type="text" id="custom-action" value="/app/dev" /> 189 + </div> 190 + <div class="dev-form-row"> 191 + <label>Facet (optional)</label> 192 + <input type="text" id="custom-facet" value="" placeholder="e.g., work" /> 188 193 </div> 189 194 <div class="dev-form-row"> 190 195 <label>Badge Count (0 = none)</label> ··· 322 327 log(`Created no-action notification (ID: ${id})`); 323 328 } 324 329 330 + function testWithFacet() { 331 + const id = window.AppServices.notifications.show({ 332 + app: 'dev', 333 + icon: '🎯', 334 + title: 'Facet Navigation Test', 335 + message: 'Clicking this will navigate to /app/home and select "work" facet', 336 + action: '/app/home', 337 + facet: 'work' 338 + }); 339 + lastNotificationId = id; 340 + log(`Created facet notification (ID: ${id})`); 341 + } 342 + 325 343 // Custom Notification 326 344 function testCustom() { 327 345 const app = document.getElementById('custom-app').value; ··· 329 347 const title = document.getElementById('custom-title').value; 330 348 const message = document.getElementById('custom-message').value; 331 349 const action = document.getElementById('custom-action').value || null; 350 + const facet = document.getElementById('custom-facet').value || null; 332 351 const badge = parseInt(document.getElementById('custom-badge').value) || null; 333 352 const autoDismiss = parseInt(document.getElementById('custom-autodismiss').value) || null; 334 353 const dismissible = document.getElementById('custom-dismissible').checked; ··· 339 358 title, 340 359 message, 341 360 action, 361 + facet, 342 362 badge: badge > 0 ? badge : null, 343 363 autoDismiss: autoDismiss > 0 ? autoDismiss : null, 344 364 dismissible
-24
apps/home/__init__.py
··· 1 - """Home app - main dashboard and overview.""" 2 - 3 - from __future__ import annotations 4 - 5 - from apps import BaseApp 6 - 7 - 8 - class HomeApp(BaseApp): 9 - """Home app implementation.""" 10 - 11 - name = "home" 12 - icon = "🏠" 13 - label = "Home" 14 - 15 - def get_blueprint(self): 16 - from .routes import home_bp 17 - 18 - return home_bp 19 - 20 - def get_workspace_template(self): 21 - return "workspace.html" 22 - 23 - def get_service_template(self): 24 - return "service.html"
+4
apps/home/app.json
··· 1 + { 2 + "icon": "🏠", 3 + "label": "Home" 4 + }
-1
apps/home/routes.py
··· 7 7 home_bp = Blueprint( 8 8 "home_app", 9 9 __name__, 10 - template_folder="templates", 11 10 url_prefix="/app/home", 12 11 ) 13 12
apps/home/templates/service.html apps/home/service.html
apps/home/templates/workspace.html apps/home/workspace.html
+11
convey/__init__.py
··· 13 13 14 14 from flask import Flask, request 15 15 from flask_sock import Sock 16 + from jinja2 import ChoiceLoader, FileSystemLoader 16 17 17 18 from apps import AppRegistry 18 19 from think import messages as message_store ··· 143 144 template_folder=os.path.join(os.path.dirname(__file__), "templates"), 144 145 static_folder=os.path.join(os.path.dirname(__file__), "static"), 145 146 ) 147 + 148 + # Add apps directory to template search path so apps can have their templates 149 + # in apps/{name}/workspace.html instead of needing a templates/ subfolder 150 + convey_templates = os.path.join(os.path.dirname(__file__), "templates") 151 + apps_root = os.path.join(os.path.dirname(os.path.dirname(__file__)), "apps") 152 + app.jinja_loader = ChoiceLoader([ 153 + FileSystemLoader(convey_templates), 154 + FileSystemLoader(apps_root), 155 + ]) 156 + 146 157 app.secret_key = os.getenv("CONVEY_SECRET", "sunstone-secret") 147 158 app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30) 148 159