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 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 )