personal memory agent
at main 342 lines 12 kB view raw
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4"""App plugin system for solstone. 5 6Convention-based app discovery with minimal configuration: 7 8Directory Structure: 9 apps/my_app/ # Use underscores, not hyphens! 10 workspace.html # Required: Main template 11 routes.py # Optional: Flask blueprint (for custom routes beyond index) 12 background.html # Optional: Background service 13 app.json # Optional: Metadata overrides 14 agents/ # Optional: Custom agents 15 tests/ # Optional: App-specific tests 16 17Naming Rules: 18 - App directory names must use underscores (my_app), not hyphens (my-app) 19 - App name = directory name (e.g., "my_app") 20 - Blueprint variable must be named {app_name}_bp (e.g., my_app_bp) 21 - Blueprint name must use "app:{name}" pattern for consistency 22 (e.g., Blueprint("app:home", ...), use url_for('app:home.index')) 23 - URL prefix convention: /app/{app_name} 24 25app.json fields (all optional): 26 { 27 "icon": "🏠", # Emoji icon for menu bar (default: "📦") 28 "label": "Custom Label", # Display label (default: title-cased app name) 29 "facets": {}, # Facet options: {"disabled": true} to hide facet bar 30 "date_nav": true, # Show date navigation bar (default: false) 31 "allow_future_dates": true # Allow future dates in month picker (default: false) 32 } 33 34 See the App dataclass below for the complete field list with types and defaults. 35 36Apps are automatically discovered and registered. 37All apps are served at /app/{name} via shared handler. 38Apps with routes.py can define custom routes beyond the index route. 39""" 40 41from __future__ import annotations 42 43import importlib 44import json 45import logging 46from dataclasses import dataclass, field 47from pathlib import Path 48from typing import Any, Optional 49 50from flask import Blueprint 51 52logger = logging.getLogger(__name__) 53 54 55@dataclass 56class App: 57 """Convention-based app configuration.""" 58 59 name: str 60 icon: str 61 label: str 62 blueprint: Optional[Blueprint] = None 63 64 # Template paths (relative to Flask template root) 65 workspace_template: str = "" 66 background_template: Optional[str] = None 67 68 # Facet configuration (optional, default {}) 69 # Options: 70 # - disabled: If true, facets bar is hidden for this app 71 facets_config: dict = field(default_factory=dict) 72 73 # Date navigation (renders date nav below facet bar) 74 date_nav: bool = False 75 76 # Allow clicking future dates in month picker (for todos) 77 allow_future_dates: bool = False 78 79 def facets_enabled(self) -> bool: 80 """Check if facets are enabled for this app.""" 81 return not self.facets_config.get("disabled", False) 82 83 def date_nav_enabled(self) -> bool: 84 """Check if date nav is enabled for this app.""" 85 return self.date_nav 86 87 def get_blueprint(self) -> Optional[Blueprint]: 88 """Return Flask Blueprint with app routes, or None if app has no custom routes.""" 89 return self.blueprint 90 91 def get_workspace_template(self) -> str: 92 """Return path to workspace template.""" 93 return self.workspace_template 94 95 def get_background_template(self) -> Optional[str]: 96 """Return path to background service template, or None.""" 97 return self.background_template 98 99 100class AppRegistry: 101 """Registry for discovering and managing solstone apps.""" 102 103 def __init__(self): 104 self.apps: dict[str, App] = {} 105 106 def discover(self) -> None: 107 """Auto-discover apps using convention over configuration. 108 109 For each directory in apps/: 110 1. Check for workspace.html (required) 111 2. Load app.json if present (for icon, label overrides) 112 3. Import routes.py and get blueprint (optional - for custom routes) 113 4. Check for background.html (optional) 114 """ 115 apps_dir = Path(__file__).parent 116 117 for app_path in sorted(apps_dir.iterdir()): 118 # Skip non-directories and private/internal directories 119 if not app_path.is_dir() or app_path.name.startswith("_"): 120 continue 121 122 app_name = app_path.name 123 124 # Skip if workspace.html doesn't exist (required) 125 if not (app_path / "workspace.html").exists(): 126 logger.debug(f"Skipping {app_name}/ - no workspace.html found") 127 continue 128 129 try: 130 app = self._load_app(app_name, app_path) 131 self.apps[app_name] = app 132 logger.info(f"Discovered app: {app_name}") 133 except Exception as e: 134 logger.error(f"Failed to load app {app_name}: {e}", exc_info=True) 135 136 def _load_app(self, app_name: str, app_path: Path) -> App: 137 """Load a single app from its directory. 138 139 Args: 140 app_name: Name of the app (directory name) 141 app_path: Path to app directory 142 143 Returns: 144 App instance 145 146 Raises: 147 Exception: If app cannot be loaded 148 """ 149 # Validate app name 150 if "-" in app_name: 151 logger.warning( 152 f"App '{app_name}' uses hyphens. Use underscores instead (e.g., 'my_app')" 153 ) 154 155 # Load metadata from app.json (optional) 156 metadata = self._load_metadata(app_path) 157 158 # Get icon and label (with defaults) 159 icon = metadata.get("icon", "📦") 160 label = metadata.get("label", app_name.replace("_", " ").title()) 161 162 # Parse facets config 163 facets_config = metadata.get("facets", {}) 164 if not isinstance(facets_config, dict): 165 facets_config = {} 166 167 # Date navigation 168 date_nav = metadata.get("date_nav", False) 169 170 # Allow future dates in month picker 171 allow_future_dates = metadata.get("allow_future_dates", False) 172 173 # Import routes module and get blueprint (optional) 174 blueprint = None 175 routes_module = None 176 routes_file = app_path / "routes.py" 177 178 if routes_file.exists(): 179 routes_module = importlib.import_module(f"apps.{app_name}.routes") 180 181 # Find blueprint - look for *_bp attribute 182 expected_bp_var = f"{app_name}_bp" 183 184 for attr_name in dir(routes_module): 185 if attr_name.endswith("_bp"): 186 bp = getattr(routes_module, attr_name) 187 if isinstance(bp, Blueprint): 188 blueprint = bp 189 190 # Warn if variable name doesn't match convention 191 if attr_name != expected_bp_var: 192 logger.warning( 193 f"App '{app_name}': Blueprint variable '{attr_name}' should be '{expected_bp_var}'" 194 ) 195 196 break 197 198 if not blueprint: 199 raise ValueError( 200 f"No blueprint found in apps.{app_name}.routes - " 201 f"expected variable named '{expected_bp_var}'" 202 ) 203 204 # Verify blueprint name uses "app:{name}" pattern for consistency 205 expected_name = f"app:{app_name}" 206 if blueprint.name != expected_name: 207 raise ValueError( 208 f"App '{app_name}': Blueprint name must be '{expected_name}', " 209 f"got '{blueprint.name}'. Update Blueprint() declaration in routes.py" 210 ) 211 else: 212 # No routes.py - create a minimal blueprint 213 blueprint = self._create_minimal_blueprint(app_name) 214 logger.debug( 215 f"Created minimal blueprint for app '{app_name}' (no routes.py)" 216 ) 217 218 # Inject default index route if app doesn't define one 219 self._inject_index_if_needed(blueprint, routes_module, app_name) 220 221 # Resolve template paths (relative to apps/ directory since that's in the loader) 222 workspace_template = f"{app_name}/workspace.html" 223 224 background_template = None 225 if (app_path / "background.html").exists(): 226 background_template = f"{app_name}/background.html" 227 228 return App( 229 name=app_name, 230 icon=icon, 231 label=label, 232 blueprint=blueprint, 233 workspace_template=workspace_template, 234 background_template=background_template, 235 facets_config=facets_config, 236 date_nav=date_nav, 237 allow_future_dates=allow_future_dates, 238 ) 239 240 def _load_metadata(self, app_path: Path) -> dict[str, Any]: 241 """Load app.json metadata file if it exists. 242 243 Args: 244 app_path: Path to app directory 245 246 Returns: 247 Dict with metadata, or empty dict if no app.json 248 """ 249 metadata_file = app_path / "app.json" 250 if metadata_file.exists(): 251 try: 252 with open(metadata_file) as f: 253 return json.load(f) 254 except Exception as e: 255 logger.warning(f"Failed to load {metadata_file}: {e}") 256 return {} 257 258 def _create_minimal_blueprint(self, app_name: str) -> Blueprint: 259 """Create a minimal blueprint for apps without routes.py. 260 261 Args: 262 app_name: Name of the app 263 264 Returns: 265 Blueprint with proper naming and URL prefix 266 """ 267 blueprint = Blueprint( 268 f"app:{app_name}", 269 __name__, 270 url_prefix=f"/app/{app_name}", 271 ) 272 return blueprint 273 274 def _inject_index_if_needed( 275 self, blueprint: Blueprint, routes_module: Any, app_name: str 276 ) -> None: 277 """Inject default index route if app doesn't define one. 278 279 Checks if routes module has an 'index' function. If not, adds a 280 default index route that renders app.html using blueprint.record() 281 to support multiple app registrations. 282 283 Args: 284 blueprint: The Flask blueprint to inject into 285 routes_module: The imported routes module (or None if no routes.py) 286 app_name: Name of the app 287 """ 288 import inspect 289 290 has_index = False 291 292 if routes_module: 293 # Get functions defined in this module (not imported) 294 module_functions = [ 295 name 296 for name, obj in inspect.getmembers(routes_module) 297 if inspect.isfunction(obj) and obj.__module__ == routes_module.__name__ 298 ] 299 has_index = "index" in module_functions 300 301 if not has_index: 302 # No index function, inject default one using record() for deferred setup 303 # Only inject if blueprint hasn't been registered yet 304 if not blueprint._got_registered_once: 305 306 def index(): 307 from flask import render_template 308 309 return render_template("app.html") 310 311 def setup_index(state): 312 """Deferred setup function called when blueprint is registered.""" 313 state.app.add_url_rule( 314 f"{blueprint.url_prefix}/", 315 endpoint=f"{blueprint.name}.index", 316 view_func=index, 317 ) 318 319 blueprint.record(setup_index) 320 logger.debug(f"Injected default index route for app '{app_name}'") 321 322 def register_blueprints(self, flask_app) -> None: 323 """Register all app blueprints with Flask. 324 325 Args: 326 flask_app: Flask application instance 327 """ 328 for app in self.apps.values(): 329 if not app.blueprint: 330 logger.error( 331 f"App '{app.name}' has no blueprint - this should not happen" 332 ) 333 continue 334 335 try: 336 flask_app.register_blueprint(app.blueprint) 337 logger.info(f"Registered blueprint: {app.blueprint.name}") 338 except Exception as e: 339 logger.error( 340 f"Failed to register blueprint for app {app.name}: {e}", 341 exc_info=True, 342 )