personal memory agent
0
fork

Configure Feed

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

at scratch/segment-sense-rd 347 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, {"muted": true} to show disabled facets 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 # - muted: Include facets marked as disabled in facet.json 72 facets_config: dict = field(default_factory=dict) 73 74 # Date navigation (renders date nav below facet bar) 75 date_nav: bool = False 76 77 # Allow clicking future dates in month picker (for todos) 78 allow_future_dates: bool = False 79 80 def facets_enabled(self) -> bool: 81 """Check if facets are enabled for this app.""" 82 return not self.facets_config.get("disabled", False) 83 84 def show_muted_facets(self) -> bool: 85 """Check if muted/disabled facets should be shown.""" 86 return self.facets_config.get("muted", False) 87 88 def date_nav_enabled(self) -> bool: 89 """Check if date nav is enabled for this app.""" 90 return self.date_nav 91 92 def get_blueprint(self) -> Optional[Blueprint]: 93 """Return Flask Blueprint with app routes, or None if app has no custom routes.""" 94 return self.blueprint 95 96 def get_workspace_template(self) -> str: 97 """Return path to workspace template.""" 98 return self.workspace_template 99 100 def get_background_template(self) -> Optional[str]: 101 """Return path to background service template, or None.""" 102 return self.background_template 103 104 105class AppRegistry: 106 """Registry for discovering and managing solstone apps.""" 107 108 def __init__(self): 109 self.apps: dict[str, App] = {} 110 111 def discover(self) -> None: 112 """Auto-discover apps using convention over configuration. 113 114 For each directory in apps/: 115 1. Check for workspace.html (required) 116 2. Load app.json if present (for icon, label overrides) 117 3. Import routes.py and get blueprint (optional - for custom routes) 118 4. Check for background.html (optional) 119 """ 120 apps_dir = Path(__file__).parent 121 122 for app_path in sorted(apps_dir.iterdir()): 123 # Skip non-directories and private/internal directories 124 if not app_path.is_dir() or app_path.name.startswith("_"): 125 continue 126 127 app_name = app_path.name 128 129 # Skip if workspace.html doesn't exist (required) 130 if not (app_path / "workspace.html").exists(): 131 logger.debug(f"Skipping {app_name}/ - no workspace.html found") 132 continue 133 134 try: 135 app = self._load_app(app_name, app_path) 136 self.apps[app_name] = app 137 logger.info(f"Discovered app: {app_name}") 138 except Exception as e: 139 logger.error(f"Failed to load app {app_name}: {e}", exc_info=True) 140 141 def _load_app(self, app_name: str, app_path: Path) -> App: 142 """Load a single app from its directory. 143 144 Args: 145 app_name: Name of the app (directory name) 146 app_path: Path to app directory 147 148 Returns: 149 App instance 150 151 Raises: 152 Exception: If app cannot be loaded 153 """ 154 # Validate app name 155 if "-" in app_name: 156 logger.warning( 157 f"App '{app_name}' uses hyphens. Use underscores instead (e.g., 'my_app')" 158 ) 159 160 # Load metadata from app.json (optional) 161 metadata = self._load_metadata(app_path) 162 163 # Get icon and label (with defaults) 164 icon = metadata.get("icon", "📦") 165 label = metadata.get("label", app_name.replace("_", " ").title()) 166 167 # Parse facets config 168 facets_config = metadata.get("facets", {}) 169 if not isinstance(facets_config, dict): 170 facets_config = {} 171 172 # Date navigation 173 date_nav = metadata.get("date_nav", False) 174 175 # Allow future dates in month picker 176 allow_future_dates = metadata.get("allow_future_dates", False) 177 178 # Import routes module and get blueprint (optional) 179 blueprint = None 180 routes_module = None 181 routes_file = app_path / "routes.py" 182 183 if routes_file.exists(): 184 routes_module = importlib.import_module(f"apps.{app_name}.routes") 185 186 # Find blueprint - look for *_bp attribute 187 expected_bp_var = f"{app_name}_bp" 188 189 for attr_name in dir(routes_module): 190 if attr_name.endswith("_bp"): 191 bp = getattr(routes_module, attr_name) 192 if isinstance(bp, Blueprint): 193 blueprint = bp 194 195 # Warn if variable name doesn't match convention 196 if attr_name != expected_bp_var: 197 logger.warning( 198 f"App '{app_name}': Blueprint variable '{attr_name}' should be '{expected_bp_var}'" 199 ) 200 201 break 202 203 if not blueprint: 204 raise ValueError( 205 f"No blueprint found in apps.{app_name}.routes - " 206 f"expected variable named '{expected_bp_var}'" 207 ) 208 209 # Verify blueprint name uses "app:{name}" pattern for consistency 210 expected_name = f"app:{app_name}" 211 if blueprint.name != expected_name: 212 raise ValueError( 213 f"App '{app_name}': Blueprint name must be '{expected_name}', " 214 f"got '{blueprint.name}'. Update Blueprint() declaration in routes.py" 215 ) 216 else: 217 # No routes.py - create a minimal blueprint 218 blueprint = self._create_minimal_blueprint(app_name) 219 logger.debug( 220 f"Created minimal blueprint for app '{app_name}' (no routes.py)" 221 ) 222 223 # Inject default index route if app doesn't define one 224 self._inject_index_if_needed(blueprint, routes_module, app_name) 225 226 # Resolve template paths (relative to apps/ directory since that's in the loader) 227 workspace_template = f"{app_name}/workspace.html" 228 229 background_template = None 230 if (app_path / "background.html").exists(): 231 background_template = f"{app_name}/background.html" 232 233 return App( 234 name=app_name, 235 icon=icon, 236 label=label, 237 blueprint=blueprint, 238 workspace_template=workspace_template, 239 background_template=background_template, 240 facets_config=facets_config, 241 date_nav=date_nav, 242 allow_future_dates=allow_future_dates, 243 ) 244 245 def _load_metadata(self, app_path: Path) -> dict[str, Any]: 246 """Load app.json metadata file if it exists. 247 248 Args: 249 app_path: Path to app directory 250 251 Returns: 252 Dict with metadata, or empty dict if no app.json 253 """ 254 metadata_file = app_path / "app.json" 255 if metadata_file.exists(): 256 try: 257 with open(metadata_file) as f: 258 return json.load(f) 259 except Exception as e: 260 logger.warning(f"Failed to load {metadata_file}: {e}") 261 return {} 262 263 def _create_minimal_blueprint(self, app_name: str) -> Blueprint: 264 """Create a minimal blueprint for apps without routes.py. 265 266 Args: 267 app_name: Name of the app 268 269 Returns: 270 Blueprint with proper naming and URL prefix 271 """ 272 blueprint = Blueprint( 273 f"app:{app_name}", 274 __name__, 275 url_prefix=f"/app/{app_name}", 276 ) 277 return blueprint 278 279 def _inject_index_if_needed( 280 self, blueprint: Blueprint, routes_module: Any, app_name: str 281 ) -> None: 282 """Inject default index route if app doesn't define one. 283 284 Checks if routes module has an 'index' function. If not, adds a 285 default index route that renders app.html using blueprint.record() 286 to support multiple app registrations. 287 288 Args: 289 blueprint: The Flask blueprint to inject into 290 routes_module: The imported routes module (or None if no routes.py) 291 app_name: Name of the app 292 """ 293 import inspect 294 295 has_index = False 296 297 if routes_module: 298 # Get functions defined in this module (not imported) 299 module_functions = [ 300 name 301 for name, obj in inspect.getmembers(routes_module) 302 if inspect.isfunction(obj) and obj.__module__ == routes_module.__name__ 303 ] 304 has_index = "index" in module_functions 305 306 if not has_index: 307 # No index function, inject default one using record() for deferred setup 308 # Only inject if blueprint hasn't been registered yet 309 if not blueprint._got_registered_once: 310 311 def index(): 312 from flask import render_template 313 314 return render_template("app.html") 315 316 def setup_index(state): 317 """Deferred setup function called when blueprint is registered.""" 318 state.app.add_url_rule( 319 f"{blueprint.url_prefix}/", 320 endpoint=f"{blueprint.name}.index", 321 view_func=index, 322 ) 323 324 blueprint.record(setup_index) 325 logger.debug(f"Injected default index route for app '{app_name}'") 326 327 def register_blueprints(self, flask_app) -> None: 328 """Register all app blueprints with Flask. 329 330 Args: 331 flask_app: Flask application instance 332 """ 333 for app in self.apps.values(): 334 if not app.blueprint: 335 logger.error( 336 f"App '{app.name}' has no blueprint - this should not happen" 337 ) 338 continue 339 340 try: 341 flask_app.register_blueprint(app.blueprint) 342 logger.info(f"Registered blueprint: {app.blueprint.name}") 343 except Exception as e: 344 logger.error( 345 f"Failed to register blueprint for app {app.name}: {e}", 346 exc_info=True, 347 )