personal memory agent
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 )