This document is for developers working on conversation persistence.
lumo-tamer supports two conversation stores:
- ConversationStore: encrypted offline persistence and full sync reusing WebClient's code
- FallbackStore: in-memory, optional one-way sync
ConversationStore is the way forward, but still new. It will allow future lumo-tamer versions to make Lumo remember and search past converations.
However, FallbackStore is the default for now (useFallbackStore: true) because:
- ConversationStore needs more testing (general performance and performance with
loginandrcloneauthentications) - Persistence is not required for the core functionality of chatting with Lumo
To sync conversations with other Lumo instances (web- or mobile apps), browser authentication is required.
conversations:
useFallbackStore: true # true = fallback, false = ConversationStore (default: true)
enableSync: false # Enable server sync (requires browser auth)
projectName: lumo-tamer # Project name (created if doesn't exist)
deriveIdFromUser: false # For stateless clients (Home Assistant)
databasePath: "sessions/" # IndexedDB SQLite files locationReuses Proton's WebClient infrastructure for local persistence and server sync.
ConversationStore (adapter)
→ Redux store (in-memory state)
→ IndexedDB (encrypted offline persistence via indexeddbshim -> SQLite)
→ Sagas (automatic server sync)
- Import IndexedDB polyfill (
src/shims/indexeddb-polyfill.js) - Create DbApi (initialize IndexedDB -> SQLite)
- Create saga middleware with context (dbApi, lumoApi)
- Setup Redux store
- Start root saga (triggers IDB load)
- Dispatch
addMasterKey(triggers initAppSaga) - Wait for Redux to load from IDB
- Wait for remote spaces to be fetched
- Find or create space by
projectName - Return ConversationStore adapter
lumo-tamer reuses the Lumo WebClient's encryption layer. Both offline storage and data synced to Lumo is encryped using following keys:
- Master Key: Fetched from
/lumo/v1/masterkeys, decrypted with user's PGP private key - Space Key: Generated per-space, wrapped with master key using AES-KW
- Data Encryption Key (DEK): Derived from space key using HKDF with fixed salt
- Content: Encrypted with AES-GCM using DEK
When authenticated via browser, sync is automatic via Redux sagas:
- Messages and conversations are marked dirty in IndexedDB
- Sagas detect dirty items and push to server
- Sync state persists across restarts
- semanticId: Call ID for tool messages, hash(role+content) for regular messages
findNewMessages(): Compares incoming messages against stored messagesisValidContinuation(): Validates no branching in conversation tree
Call /save [optional title] to save stateless conversations. See troubleshooting.
| Component | Location | Purpose |
|---|---|---|
| ConversationStore | src/conversations/store.ts | Adapter wrapping Redux for lumo-tamer's interface |
| Store initialization | src/conversations/init.ts | Sets up Redux, IndexedDB, sagas, resolves project space |
| KeyManager | src/conversations/key-manager.ts | Master/space key management |
| Deduplication | src/conversations/deduplication.ts | Message deduplication via semantic IDs |
| Redux slices | packages/lumo/src/redux/slices/core/ | State for spaces, conversations, messages |
| Redux sagas | packages/lumo/src/redux/sagas/ | Async sync operations (push/pull) |
| IndexedDB layer | packages/lumo/src/indexedDb/db.ts | DbApi for local SQLite storage |
Legacy in-memory cache for environments without full persistence support.
FallbackStore (in-memory LRU)
→ SyncService (manual sync to server)
→ SpaceManager (space lifecycle)
→ EncryptionCodec (AEAD encryption)
→ AutoSyncService
When authenticated via browser and enableSync: true:
FallbackStore.markDirtyById()notifiesAutoSyncService- Debounce: Waits 5s for activity to settle
- Throttle: Respects 30s minimum interval
- Max delay: Forces sync after 60s
- Auto syncs on exit.
| Component | Location | Purpose |
|---|---|---|
| FallbackStore | src/conversations/fallback/store.ts | In-memory Map with LRU eviction |
| SyncService | src/conversations/fallback/sync/sync-service.ts | Orchestrates server sync |
| SpaceManager | src/conversations/fallback/sync/space-manager.ts | Space lifecycle and key management |
| EncryptionCodec | src/conversations/fallback/sync/encryption-codec.ts | AEAD encryption/decryption |
| AutoSyncService | src/conversations/fallback/sync/auto-sync.ts | Debounced/throttled sync |
So far, only the browser authentication method is able to fetch all necessary tokens and keys to encrypt messages for storage, and call Lumo's API endpoints to save them.
Proton's backend enforces a per-project conversation limit. Deleted conversations count towards this limit. When reached, sync fails with HTTP 422 "You've reached maximum number of conversations". Use a new projectName to work around this. See #16.
Solution: ConversationStore requires cached encryption keys. Re-authenticate to save/generate them.
Cause: Your API client isn't providing a conversation identifier, so lumo-tamer treats requests as stateless.
Solution: Configure your client to send a conversation identifier.
- Include
"conversation": "your-conversation-id"in the request (/v1/responses) - Use
previous_response_idto chain responses together (/v1/responses) - Include
"user": "unique-session-id"and setderiveIdFromUser: true(/v1/responsesand/v1/chat/completions)
Example:
# config.yaml
conversations:
deriveIdFromUser: true
enableSync: true// API request
{
"model": "lumo",
"user": "session-abc123",
"messages": [{"role": "user", "content": "Hello"}]
}How to verify it's working:
- Check logs for
Persisted conversation messages(stateful) vs no persistence log (stateless) - Check logs for
Generated titleon first message - After sync, check Proton Lumo WebClient for the conversation
Reference material based on analysis of https://github.com/ProtonMail/WebClients/applications/lumo/src/app/.
Three-tier persistence:
UI (React) -> Redux -> Saga Middleware -> IndexedDB (local) + Remote API (server)
- Redux - In-memory state for fast UI
- IndexedDB - Local encrypted storage, offline-first
- Remote API - Server-side persistence (
/api/lumo/v1/)
Container for conversations with its own encryption key.
type Space = {
id: SpaceId; // UUID
createdAt: string;
spaceKey: Base64; // HKDF-derived, wrapped with master key
};type Conversation = {
id: ConversationId;
spaceId: SpaceId;
title: string; // Encrypted
starred?: boolean;
status?: 'generating' | 'completed';
ghost?: boolean; // Transient, not persisted
};type Message = {
id: MessageId;
conversationId: ConversationId;
role: 'user' | 'assistant' | 'system' | 'tool_call' | 'tool_result';
parentId?: MessageId; // For branching
content?: string; // Encrypted
status?: 'succeeded' | 'failed';
};All encrypted content uses associated data to bind ciphertext to its context. AD is a JSON object with alphabetically sorted keys (via json-stable-stringify):
// Space AD
{"app":"lumo","id":"<spaceId>","type":"space"}
// Conversation AD
{"app":"lumo","id":"<conversationId>","spaceId":"<spaceId>","type":"conversation"}
// Message AD
{"app":"lumo","conversationId":"<convId>","id":"<messageId>","parentId":"<parentId>","role":"user|assistant","type":"message"}Base URL: /api/lumo/v1/
| Resource | Endpoints |
|---|---|
| Spaces | GET/POST /spaces, GET/PUT/DELETE /spaces/{id} |
| Conversations | POST /spaces/{spaceId}/conversations, GET/PUT/DELETE /conversations/{id} |
| Messages | POST /conversations/{id}/messages, GET /messages/{id} |
| Master Keys | GET/POST /masterkeys |
| Path | Purpose |
|---|---|
src/app/types.ts |
Data structures |
src/app/remote/api.ts |
HTTP client |
src/app/indexedDb/db.ts |
IndexedDB operations |
src/app/redux/sagas/conversations.ts |
Sync orchestration |
src/app/serialization.ts |
Encryption helpers |