Mark all phases as complete (v1.5.0-v1.6.0): - Settings DB, Phase A, B, C all implemented - Updated files summary with actual implementations - Added remaining work section for file upload placeholder 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
27 KiB
Memory "Remember" Triggers - Implementation Plan
Overview
This document outlines the implementation of "remember" triggers for the memory system. Currently, we have recall (search) working for volatile and documents, but no automated triggers to populate these memory tiers.
Key architectural principle:
- Scheduler-driven: Prefetch data that's useful on a repeating schedule (weather, news)
- HybridRAG-driven: Cache ad-hoc ephemeral data discovered during searches
- Learning loop: HybridRAG can register scheduler tasks when it discovers prefetch-worthy patterns
Current State
| Memory Tier | Remember Trigger | Recall | Status |
|---|---|---|---|
| Wiki | Wiki.js webhook, Consolidation | HybridRAG vector+graph | ✅ Complete |
| Documents | Paperless webhook | HybridRAG document search | ✅ Complete (v1.6.0) |
| Volatile | Scheduler prefetch, HybridRAG post-processor | HybridRAG volatile search | ✅ Complete (v1.6.0) |
Implementation Summary (v1.6.0)
- Settings DB: Central
system_settingsPostgreSQL database withSettingsClient - Phase A: Volatile fetch endpoints (
/volatile/fetch/{namespace}/{key}) with weather, news, financial providers - Phase B: Unified memory routing in consolidation service (wiki/volatile/file/prefetch/skip classification)
- Phase C: Document recall in HybridRAG (4-source parallel retrieval)
- Scheduler Integration:
SchedulerClientfor external scheduler task registration
Architecture
┌─────────────────────────────────────────────────────────────────────────┐
│ REMEMBER TRIGGERS │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────┐ │
│ │ HybridRAG Search │ │
│ │ Post-processor │ │
│ └──────────┬───────────┘ │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌───────────┐ ┌─────────────────┐ │
│ │ Classify │ │ Store │ │ Register │ │
│ │ web results │ │ immediate │ │ scheduler task │ │
│ └──────┬──────┘ │ (volatile)│ │ (if prefetch │ │
│ │ │ short TTL │ │ worthy) │ │
│ │ └───────────┘ └────────┬────────┘ │
│ │ │ │
│ ┌─────────────┼─────────────┐ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌───────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │
│ │ PDF │ │ Ephemeral│ │ Prefetch │ │ Scheduler │ │
│ │ │ │ (1x use) │ │ worthy │ │ (external) │ │
│ └───┬───┘ └────┬─────┘ └────┬─────┘ └──────┬──────┘ │
│ │ │ │ │ │
│ ▼ ▼ │ │ │
│ ┌────────┐ ┌─────────┐ │ │ │
│ │Paperless│ │Volatile │ │ ┌─────────────┘ │
│ │Documents│ │short TTL│ │ │ │
│ └────────┘ └─────────┘ │ ▼ │
│ │ ┌─────────────────┐ │
│ └─►│ POST /volatile/ │ │
│ │ fetch (cron) │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Volatile │ │
│ │ long TTL │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Central Settings Database
Rationale
External API credentials (NewsAPI, etc.) and configs (Open-Meteo) should NOT be in environment variables because:
- They're not deployment-specific (same across all environments)
- They change independently of deployments
- Multiple services across Tatlock need access to shared credentials
- Environment variables require container restarts to update
Database Choice: PostgreSQL
Decision: Use postgres-shared container (existing Tatlock infrastructure).
Create a new database system_settings on the shared PostgreSQL instance. This container exists specifically for cross-service databases.
Schema Design
-- Run on postgres-shared as admin user
-- Create database
CREATE DATABASE system_settings;
-- Create settings user (shared across all Tatlock services)
CREATE USER settings WITH PASSWORD 'changeme';
GRANT ALL PRIVILEGES ON DATABASE system_settings TO settings;
-- Connect to system_settings database
\c system_settings
-- Create table
CREATE TABLE settings (
key VARCHAR(255) NOT NULL,
user_scope VARCHAR(100) NOT NULL DEFAULT 'global', -- 'global' or specific username
value JSONB NOT NULL,
schema JSONB, -- JSON Schema for UI rendering (nullable)
description TEXT,
updated_at TIMESTAMP DEFAULT NOW(),
updated_by VARCHAR(100),
PRIMARY KEY (key, user_scope)
);
-- Index for user-scoped lookups
CREATE INDEX idx_settings_user_scope ON settings(user_scope);
-- Grant full access
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO settings;
Query Pattern
-- Get setting with user override, fallback to global
SELECT value, schema FROM settings
WHERE key = $1 AND user_scope IN ($2, 'global')
ORDER BY CASE WHEN user_scope = $2 THEN 0 ELSE 1 END
LIMIT 1;
Data Types with JSON Schema
The schema column contains JSON Schema for UI widget rendering:
| JSON Schema | UI Widget |
|---|---|
{"type": "string", "format": "password"} |
Masked input |
{"type": "string", "enum": [...]} |
Dropdown/select |
{"type": "boolean"} |
Toggle switch |
{"type": "array", "items": {"type": "string"}} |
Multi-select or list |
{"type": "number", "minimum": 0, "maximum": 100} |
Slider or number input |
| No schema | Raw JSON editor |
Example Data
-- Global API keys (with schemas for CRUD UI)
INSERT INTO settings (key, user_scope, value, schema, description) VALUES
('api.openmeteo', 'global',
'{"base_url": "https://api.open-meteo.com/v1/forecast", "timezone": "Europe/Amsterdam"}',
'{
"type": "object",
"properties": {
"base_url": {"type": "string", "format": "uri", "title": "Base URL"},
"timezone": {"type": "string", "title": "Default Timezone"}
}
}',
'Open-Meteo weather API (no API key required)'),
('api.newsapi', 'global',
'{"api_key": "xxx"}',
'{
"type": "object",
"properties": {
"api_key": {"type": "string", "format": "password", "title": "API Key"}
},
"required": ["api_key"]
}',
'NewsAPI.org credentials'),
('api.nos_rss', 'global',
'{"feed_url": "https://feeds.nos.nl/nosnieuwsalgemeen"}',
'{
"type": "object",
"properties": {
"feed_url": {"type": "string", "format": "uri", "title": "Feed URL"}
}
}',
'NOS.nl RSS feed');
-- User-specific preferences (explicit choices)
INSERT INTO settings (key, user_scope, value, schema, description) VALUES
('weather.units', 'jpmschweitzer',
'"metric"',
'{"type": "string", "enum": ["metric", "imperial"], "title": "Temperature Units"}',
'Preferred temperature units'),
('news.sources', 'jpmschweitzer',
'["nos", "reuters"]',
'{
"type": "array",
"items": {"type": "string"},
"uniqueItems": true,
"title": "News Sources"
}',
'Preferred news sources');
What Goes Where
| Data Type | Storage | Examples |
|---|---|---|
| API credentials/config | Settings DB (global) | api.openmeteo, api.nos, api.alphavantage |
| Explicit user preferences | Settings DB (user-scoped) | weather.units, news.sources |
| Learned user facts | Biographer knowledge graph | Location, interests, schedule |
| Internal service URLs | ENV vars | SCHEDULER_URL, REDIS_HOST |
Key principle: Settings DB stores explicit choices. Biographer stores learned context.
Example flow for weather fetch:
- Scheduler triggers
/volatile/fetch/weather - Fetch service queries biographer: "Where does this user live?"
- Biographer returns "Rotterdam" from knowledge graph
- Fetch service reads
weather.unitspreference from settings - Calls Open-Meteo API (geocode city → lat/long → forecast) with units from settings
- Stores result in volatile cache
Library-Desk Integration
ENV vars (deployment-specific only):
# Central settings database
SYSTEM_SETTINGS_HOST=postgres-shared
SYSTEM_SETTINGS_PORT=5432
SYSTEM_SETTINGS_DB=system_settings
SYSTEM_SETTINGS_USER=settings
SYSTEM_SETTINGS_PASSWORD=xxx
# Internal service URLs (plumbing, not in settings DB)
SCHEDULER_URL=http://scheduler:8080
BIOGRAPHER_URL=http://biographer:8080
New file: src/clients/settings_client.py
"""
Client for central Tatlock settings database.
Library-desk reads settings. Writes are done via psql CLI or future CRUD manager.
"""
import asyncpg
import logging
from typing import Optional, Any
logger = logging.getLogger(__name__)
class SettingsClient:
"""Client for system_settings database."""
def __init__(self, dsn: str):
self.dsn = dsn
self._pool: Optional[asyncpg.Pool] = None
async def connect(self):
"""Initialize connection pool."""
if not self._pool:
self._pool = await asyncpg.create_pool(self.dsn, min_size=1, max_size=5)
async def close(self):
"""Close connection pool."""
if self._pool:
await self._pool.close()
async def get(self, key: str, user_scope: str = "global") -> Optional[Any]:
"""
Get a setting by key with user fallback to global.
Returns user-specific value if exists, otherwise global.
"""
await self.connect()
async with self._pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT value FROM settings
WHERE key = $1 AND user_scope IN ($2, 'global')
ORDER BY CASE WHEN user_scope = $2 THEN 0 ELSE 1 END
LIMIT 1
""",
key, user_scope
)
return row["value"] if row else None
async def get_by_prefix(self, prefix: str, user_scope: str = "global") -> dict[str, Any]:
"""Get all settings matching a key prefix (e.g., 'api.')."""
await self.connect()
async with self._pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT DISTINCT ON (key) key, value FROM settings
WHERE key LIKE $1 AND user_scope IN ($2, 'global')
ORDER BY key, CASE WHEN user_scope = $2 THEN 0 ELSE 1 END
""",
f"{prefix}%", user_scope
)
return {row["key"]: row["value"] for row in rows}
async def get_api_key(self, service: str) -> Optional[str]:
"""Convenience method to get API key for a service."""
value = await self.get(f"api.{service}")
if isinstance(value, dict):
return value.get("api_key")
return value
CLI Management
Settings are managed via direct psql commands (future CRUD manager for UI):
# Connect to settings database
psql -h postgres-shared -U settings -d system_settings
# Add global API key
INSERT INTO settings (key, value, description)
VALUES ('api.alpha_vantage', '{"api_key": "YOUR_KEY"}', 'Alpha Vantage financial API');
# Add global API key with schema for UI
INSERT INTO settings (key, value, schema, description)
VALUES ('api.alpha_vantage', '{"api_key": "YOUR_KEY"}',
'{"type": "object", "properties": {"api_key": {"type": "string", "format": "password"}}}',
'Alpha Vantage financial API');
# Add user-specific preference
INSERT INTO settings (key, user_scope, value, description)
VALUES ('weather.units', 'jpmschweitzer', '"metric"', 'Preferred temperature units');
# Update NewsAPI key
UPDATE settings
SET value = '{"api_key": "NEW_KEY"}', updated_at = NOW()
WHERE key = 'api.newsapi' AND user_scope = 'global';
# List all API keys
SELECT key, description FROM settings WHERE key LIKE 'api.%';
# List user settings with fallback
SELECT DISTINCT ON (key) key, user_scope, value FROM settings
WHERE user_scope IN ('jpmschweitzer', 'global')
ORDER BY key, CASE WHEN user_scope = 'jpmschweitzer' THEN 0 ELSE 1 END;
# View specific setting
SELECT * FROM settings WHERE key = 'api.openmeteo';
Phase A: Scheduler-Driven Volatile (Prefetch)
A.1 New Endpoint: /volatile/fetch
File: src/routers/volatile.py
@router.post("/fetch/{namespace}/{key}")
async def fetch_and_store(
namespace: str, # "weather", "news"
key: str, # "rotterdam", "nos-headlines"
user: str = Query(default=DEFAULT_USER),
):
"""
Fetch fresh data from external API and store in volatile cache.
Called by scheduler on cron schedule. Combines:
1. Call appropriate API client based on namespace
2. Store result in volatile cache with appropriate TTL
API credentials are read from system_settings database.
"""
A.2 API Clients
New files in src/clients/:
| File | API | Data Type | Refresh |
|---|---|---|---|
weather_client.py |
Open-Meteo (free, no key) | Current + forecast | Daily |
news_client.py |
NOS.nl RSS (free, no key) | Headlines | Every 6h |
financial_client.py |
Alpha Vantage / Yahoo | Stocks, crypto | On-demand |
Example: src/clients/weather_client.py
class WeatherClient:
"""Open-Meteo API client with geocoding support."""
def __init__(self, settings_client: SettingsClient):
self.settings = settings_client
self._geo_cache: dict[str, tuple[float, float]] = {}
async def _get_config(self) -> dict:
"""Get Open-Meteo config from central settings."""
return await self.settings.get("api.openmeteo")
async def _geocode(self, city: str) -> tuple[float, float]:
"""Convert city name to lat/long coordinates."""
if city.lower() in self._geo_cache:
return self._geo_cache[city.lower()]
config = await self._get_config()
url = f"{config['geocoding_url']}?name={city}&count=1"
async with httpx.AsyncClient() as client:
resp = await client.get(url)
data = resp.json()
if data.get("results"):
lat = data["results"][0]["latitude"]
lon = data["results"][0]["longitude"]
self._geo_cache[city.lower()] = (lat, lon)
return (lat, lon)
raise ValueError(f"Could not geocode city: {city}")
async def get_current(self, city: str) -> dict:
"""Get current weather for city."""
config = await self._get_config()
lat, lon = await self._geocode(city)
url = (f"{config['forecast_url']}?"
f"latitude={lat}&longitude={lon}"
f"¤t=temperature_2m,weather_code,relative_humidity_2m,wind_speed_10m"
f"&timezone={config['timezone']}")
async with httpx.AsyncClient() as client:
resp = await client.get(url)
data = resp.json()
current = data["current"]
return {
"temperature": current["temperature_2m"],
"weather_code": current["weather_code"],
"humidity": current["relative_humidity_2m"],
"wind_speed": current["wind_speed_10m"],
"text": f"Currently {current['temperature_2m']}°C in {city}."
}
A.3 Fetch Service
New file: src/services/volatile_fetch_service.py
class VolatileFetchService:
"""Service to fetch external data and store in volatile cache."""
def __init__(
self,
weather_client: WeatherClient,
news_client: NewsClient,
volatile_service: VolatileCacheService,
):
self.weather = weather_client
self.news = news_client
self.volatile = volatile_service
async def fetch_weather(self, user: str, city: str) -> VolatileRecordResponse:
"""Fetch weather and store in volatile cache."""
data = await self.weather.get_current(city)
return await self.volatile.store(
user=user,
namespace="weather",
key=city.lower(),
data=data,
source="openmeteo",
ttl=86400, # 24h
)
A.4 Scheduler Configuration
| Task | Schedule | Endpoint |
|---|---|---|
volatile_weather |
0 6 * * * |
POST /volatile/fetch/weather/rotterdam?user=jpmschweitzer |
volatile_news_nos |
0 */6 * * * |
POST /volatile/fetch/news/nos?user=jpmschweitzer |
Phase B: HybridRAG-Driven Memory (Reactive)
B.1 Post-Processor Classification
Modify: src/services/hybrid_rag_service.py
Add Phase 6.5 after persistence:
async def _postprocess_for_memory(
self,
web_results: List[Dict],
query: str,
user: str,
config: HybridRAGConfig,
) -> Dict[str, Any]:
"""
Phase 6.5: Classify web results and store/register appropriately.
"""
stats = {"volatile": 0, "documents": 0, "prefetch_registered": 0}
for result in web_results:
url = result.get("url", "")
content = result.get("content", "")
content_type = self._classify_content(url, content)
if content_type == "pdf" and config.save_documents:
await self._save_to_documents(url, result.get("title"))
stats["documents"] += 1
elif content_type == "ephemeral":
if config.save_volatile:
await self._save_to_volatile(user, query, result, ttl=3600)
stats["volatile"] += 1
if config.register_prefetch:
prefetch_spec = self._should_register_prefetch(url, content, query)
if prefetch_spec:
if await self._register_prefetch_task(user, prefetch_spec):
stats["prefetch_registered"] += 1
return stats
B.2 Content Classification
def _classify_content(self, url: str, content: str) -> str:
"""
Classify web result for memory routing.
Returns: "pdf", "ephemeral", "skip"
"""
if url.endswith(".pdf"):
return "pdf"
ephemeral_domains = [
"weather.com", "open-meteo.com", "buienradar",
"nos.nl", "nu.nl", "reuters.com",
"yahoo.com/finance", "marketwatch.com",
]
if any(domain in url for domain in ephemeral_domains):
return "ephemeral"
return "skip"
B.3 Prefetch Detection
def _should_register_prefetch(self, url: str, content: str, query: str) -> Optional[dict]:
"""
Determine if content is worth registering for scheduled prefetch.
"""
# Weather patterns
weather_match = re.search(r"weather.*(?:in|for)\s+(\w+)", query, re.IGNORECASE)
if weather_match and any(d in url for d in ["weather.com", "open-meteo.com", "buienradar"]):
return {
"namespace": "weather",
"key": weather_match.group(1).lower(),
"schedule": "0 6 * * *",
"description": f"Weather for {weather_match.group(1)}",
}
# News patterns
if "nos.nl" in url:
return {
"namespace": "news",
"key": "nos",
"schedule": "0 */6 * * *",
"description": "Dutch news from NOS",
}
return None
B.4 Scheduler Client
New file: src/clients/scheduler_client.py
class SchedulerClient:
"""Client for external scheduler service."""
def __init__(self, settings_client: SettingsClient):
self.settings = settings_client
async def _get_base_url(self) -> str:
"""Get scheduler URL from central settings."""
return await self.settings.get("scheduler.base_url")
async def register_task(self, task: SchedulerTask) -> bool:
"""Register a new scheduled task."""
base_url = await self._get_base_url()
# ... POST to scheduler API ...
async def task_exists(self, task_name: str) -> bool:
"""Check if task already exists."""
# ... GET from scheduler API ...
B.5 Config Options
Modify: src/models/hybrid_rag.py
class HybridRAGConfig(BaseModel):
# ... existing fields ...
# Memory auto-save options
save_documents: bool = Field(default=False, description="Auto-upload PDFs to Paperless")
save_volatile: bool = Field(default=True, description="Auto-cache ephemeral web results")
register_prefetch: bool = Field(default=True, description="Auto-register scheduler tasks")
volatile_ttl: int = Field(default=3600, description="TTL for reactive volatile cache")
Phase C: Document Recall in HybridRAG
C.1 Add Document Search
Modify: src/services/hybrid_rag_service.py
Add to _retrieve_parallel():
if config.enable_documents:
async def document_search():
results = await self.vector.search(
query=query,
user=user,
limit=config.document_limit,
doc_type="document" # Filter to Paperless docs
)
return [{"paperless_id": r.metadata.get("paperless_id"), ...} for r in results]
tasks["document"] = document_search()
C.2 Config Options
enable_documents: bool = Field(default=True)
document_limit: int = Field(default=5)
document_threshold: float = Field(default=0.6)
Example Flow
- User searches: "What's the weather in Amsterdam?"
- HybridRAG web search: Returns open-meteo.com or weather site result
- Post-processor classifies: Ephemeral weather content
- Immediate store:
POST /volatile/store(TTL: 1h) - Prefetch detection: Matches weather pattern
- Scheduler registration: Creates task
volatile_weather_amsterdam_jpmschweitzer - Next day 6am: Scheduler calls
/volatile/fetch/weather/amsterdam - Future searches: Get cached weather from volatile
Implementation Order
| Phase | Priority | Effort | Description | Status |
|---|---|---|---|---|
| Settings DB | High | Low | PostgreSQL schema + settings client | ✅ v1.5.0 |
| B.4 | High | Low | Scheduler client | ✅ v1.6.0 |
| B.1-B.3 | High | Medium | HybridRAG post-processor | ✅ v1.6.0 |
| B.5 | High | Low | Config options | ✅ v1.6.0 |
| C.1-C.2 | High | Low | Document recall in HybridRAG | ✅ v1.6.0 |
| A.1 | Medium | Low | /volatile/fetch endpoint |
✅ v1.6.0 |
| A.2 | Medium | Medium | Weather + News API clients | ✅ v1.5.0 |
| A.3 | Medium | Low | Fetch service | ✅ v1.6.0 |
Remaining Work
| Item | Description | Status |
|---|---|---|
| File upload | Download PDFs and upload to Paperless | ⚠️ Placeholder (logs only) |
| Prefetch patterns | More sophisticated pattern detection | Optional enhancement |
Files Summary
New Files (Implemented)
| Path | Purpose | Version |
|---|---|---|
src/clients/settings_client.py |
Central settings database access | v1.5.0 |
src/clients/scheduler_client.py |
External scheduler task management | v1.6.0 |
src/apis/__init__.py |
External API providers package | v1.5.0 |
src/apis/base.py |
Abstract base classes for providers | v1.5.0 |
src/apis/weather.py |
OpenMeteoProvider (geocoding + forecast) | v1.5.0 |
src/apis/news.py |
AggregatedNewsProvider | v1.5.0 |
src/apis/nos.py |
NOSProvider (Dutch news RSS) | v1.5.0 |
src/apis/bbc.py |
BBCProvider (English news RSS) | v1.5.0 |
src/apis/financial.py |
AlphaVantageProvider (stocks/crypto) | v1.5.0 |
src/services/volatile_fetch_service.py |
Orchestrates fetch + store | v1.6.0 |
Modified Files
| Path | Changes | Version |
|---|---|---|
src/services/hybrid_rag_service.py |
Document search (4-source parallel retrieval) | v1.6.0 |
src/services/consolidation_service.py |
Unified memory routing, scheduler integration | v1.6.0 |
src/models/hybrid_rag.py |
Document config options (enable_documents, document_limit) |
v1.6.0 |
src/models/consolidation.py |
Memory routing models | v1.6.0 |
src/routers/volatile.py |
/volatile/fetch/{namespace}/{key} endpoints |
v1.6.0 |
src/core/dependencies.py |
Settings, scheduler, provider DI | v1.5.0-v1.6.0 |
src/config.py |
SYSTEM_SETTINGS_*, SCHEDULER_URL vars |
v1.5.0-v1.6.0 |
Database
| Item | Details |
|---|---|
| Database | system_settings (PostgreSQL on postgres-shared) |
| Table | settings (key, user_scope, value JSONB, schema JSONB, ...) |
| Library-desk access | Read-only via SettingsClient |
| Management | Direct psql commands (future: CRUD manager UI) |