Release v3.8.1 — feature flags settings page, bracketed combo names, security hardening, multi-driver SQLite
18 KiB
| title | version | lastUpdated |
|---|---|---|
| Memory System | 3.8.1 | 2026-05-13 |
Memory System
Source of truth:
src/lib/memory/andsrc/app/api/memory/Last updated: 2026-05-13 — v3.8.0
OmniRoute provides persistent conversational memory keyed by API key (and optionally session id). Memories are extracted automatically from LLM responses via lightweight regex pattern matching and injected back into subsequent requests as a leading system message (or first user message for providers that reject the system role).
Memory is scoped per API key, not per user — every request authenticated
with the same API key shares the same memory pool, with optional further
scoping by sessionId.
Architecture
Client → /v1/chat/completions (apiKeyInfo resolved upstream)
→ handleChatCore() [open-sse/handlers/chatCore.ts]
→ resolveMemoryOwnerId(apiKeyInfo) # extracts id
→ getMemorySettings() # cached settings
→ shouldInjectMemory(body, {enabled}) # gate
→ retrieveMemories(apiKeyId, config) # SQL + optional FTS5
→ injectMemory(body, memories, provider) # system or user message
→ upstream provider call
→ on response: extractFacts(text, apiKeyId, sessionId) # non-blocking
→ setImmediate → createMemory(fact) per match
The injection and extraction call-sites are wired in
open-sse/handlers/chatCore.ts (look for retrieveMemories, injectMemory,
and extractFacts).
Storage Layers
Primary: SQLite (memories table)
Created by migration 015_create_memories.sql:
| Column | Type | Notes |
|---|---|---|
id |
TEXT PRIMARY KEY |
UUID generated via crypto.randomUUID() |
api_key_id |
TEXT NOT NULL |
Owning API key |
session_id |
TEXT |
Optional per-conversation scope |
type |
TEXT NOT NULL |
One of factual, episodic, procedural, semantic |
key |
TEXT |
Stable upsert key, e.g. preference:i_prefer_python |
content |
TEXT NOT NULL |
The actual fact text |
metadata |
TEXT |
JSON blob (category, extractedAt, source, ...) |
created_at / updated_at |
TEXT |
ISO 8601 strings |
expires_at |
TEXT |
Optional expiry; NULL means permanent |
memory_id |
INTEGER UNIQUE |
Added by 023_fix_memory_fts_uuid.sql to bridge UUIDs ↔ FTS5 rowids |
Indexes: api_key_id, session_id, type, expires_at, plus the unique
memory_id index.
Upsert semantics: createMemory() looks for an existing row with the same
(api_key_id, key) and updates it in place when found (merging metadata via
shallow spread). This keeps the table from growing unbounded for repeated
preference statements.
Full-text Search (memory_fts virtual table)
022_add_memory_fts5.sql creates an FTS5 virtual table over content and
key. 023_fix_memory_fts_uuid.sql fixes a real-world bug where the UUID
primary key did not join to FTS5's integer rowid — the migration adds the
memory_id column, recreates the FTS table, and wires triggers
(memory_fts_ai, memory_fts_ad, memory_fts_au) that keep FTS in sync on
INSERT, DELETE, and UPDATE.
Used by retrieval.ts for the semantic and hybrid strategies (see below).
The retrieval code guards with hasTable("memory_fts") and falls back to
chronological order if the FTS table is missing or the FTS query throws.
Optional: Qdrant (vector store)
src/lib/memory/qdrant.ts implements an optional Qdrant integration for true
semantic memory:
upsertSemanticMemoryPoint()— embedkey + contentwith the configured embedding model, ensure the collection exists (creates cosine-distance vectors on first use), and upsert a point with payload{memoryId, apiKeyId, sessionId, key, content, metadata, createdAtUnix, expiresAtUnix}.searchSemanticMemory(query, topK, scope)— embed the query, search the collection filtered bykind = "omniroute_memory"and optionally byapiKeyId/sessionId. CapstopKto[1, 20].deleteSemanticMemoryPoint(id)— single point delete.cleanupSemanticMemoryPoints({retentionDays})— bulk delete points whoseexpiresAtUnixis in the past or whosecreatedAtUnixis older than the retention cutoff. Counts first so the dashboard can show actual numbers.checkQdrantHealth()—GET /readyzhealth probe with latency.
TODO: The chat pipeline (
chatCore.ts) and the in-treeretrieveMemories()implementation do not currently callupsertSemanticMemoryPointorsearchSemanticMemory. The Qdrant integration is feature-flagged viaqdrantEnabledin settings, but at the time of writing thesearchSemanticMemoryresults are not fused into retrieval — thesemantic/hybridretrieval strategies use SQLite FTS5 only. The settings UI indashboard/settings → MemorySkillsTabexposes Qdrant config, health, search test, and cleanup, but the corresponding/api/settings/qdrant,/api/settings/qdrant/health,/api/settings/qdrant/search, and/api/settings/qdrant/cleanuproutes are referenced from the UI but not present undersrc/app/api/settings/qdrant/(onlyembedding-models/is wired). Treat Qdrant as preview/optional plumbing.
Memory Types
MemoryType (src/lib/memory/types.ts):
| Type | Used for |
|---|---|
factual |
Preferences, stable user facts, behavioral patterns |
episodic |
Decisions tied to a specific moment ("I chose Postgres") |
procedural |
Workflow / how-to memory (reserved; no auto-extractor today) |
semantic |
Reserved for vector-store entries |
MemoryConfig retrieval strategy is one of exact, semantic, or hybrid,
and scope is one of session, apiKey, or global. The default scope from
getMemorySettings() is apiKey.
Fact Extraction (extraction.ts)
Extraction is regex-based, not LLM-based — it runs in-process with
setImmediate() so it never blocks the response stream:
- Preference patterns →
MemoryType.FACTUAL(e.g.I prefer …,I really like …,my favorite is …,I hate …) - Decision patterns →
MemoryType.EPISODIC(e.g.I'll use …,I chose …,I went with …,I'm going to adopt …) - Pattern patterns →
MemoryType.FACTUAL(e.g.I usually …,I always …,I tend to …)
Each match is sanitised (trim, whitespace-collapse, capped at 500 chars),
deduplicated within the batch via a stable factKey(category, content), and
stored via createMemory() with metadata
{category, extractedAt, source: "llm_response"}. Input text is capped at
64 KiB (MAX_EXTRACTION_TEXT_LENGTH) — when longer, the tail of the text
is used so the most recent assistant content always participates.
extractFactsFromText(text) is exported for tests and returns the structured
facts without storing them.
Retrieval (retrieval.ts)
retrieveMemories(apiKeyId, config) is the main entry point. It:
- Normalises and validates the config through
MemoryConfigSchema. - Returns
[]immediately whenenabledis false ormaxTokens <= 0. - Clamps
maxTokensto[1, 8000]. - Detects whether the modern
memoriestable exists (vs the legacymemorytable) so older databases keep working. - Builds the base query with expiry guard
(
expires_at IS NULL OR datetime(expires_at) > datetime('now')), optional session scope, and optionalretentionDayscutoff. - Branches on strategy:
exact(default): chronologicalORDER BY created_at DESC LIMIT 100.semantic: ifconfig.queryandmemory_ftsexists, JOINmemory_fts MATCH ?and order by FTS rank; fall back to chronological when FTS returns 0 rows.hybrid: union of FTS results (higher relevance) and the chronological set, deduplicated by id.
- Computes a keyword relevance score (
getRelevanceScore) overcontent,key, andmetadataJSON when a query is provided. Rows with zero score are filtered out. - Sorts by score desc, then
createdAtdesc. - Walks the ranked list and accepts entries while a running
estimateTokens(content)(≈length / 4) stays under the budget. Always returns at least one entry when any matched.
estimateTokens is exported and used by retrieval, summarisation, and the MCP
omniroute_memory_search tool.
Injection (injection.ts)
injectMemory(request, memories, provider):
- Joins all memory contents into a single
Memory context: …string. - Picks a strategy by provider name:
- System message (default for OpenAI, Anthropic, Gemini, …) — prepends
a
{role: "system", content: memoryText}ahead of any existing system messages so user system prompts still take precedence. - User message (fallback) — for providers in
PROVIDERS_WITHOUT_SYSTEM_MESSAGE:o1,o1-mini,o1-preview,glm,glmt,glm-cn,zai,qianfan. These reject the system role and would 400 otherwise (cf. issue #1701 for GLM/Zhipu).
- System message (default for OpenAI, Anthropic, Gemini, …) — prepends
a
- Logs the count, strategy, and model under
memory.injection.injected.
providerSupportsSystemMessage(provider) is exported for callers that need to
make routing decisions of their own. Unknown providers default to true
(system role allowed) for safety.
Settings (settings.ts)
Memory configuration is stored in the DB settings table, not in env vars.
getMemorySettings() reads from getSettings() and caches the result
in-process; invalidateMemorySettingsCache() is called by the settings PUT
route after writes.
| DB key | Type | Default | UI control |
|---|---|---|---|
memoryEnabled |
boolean | true |
Memory on/off |
memoryMaxTokens |
integer | 2000 (range 0–16000) |
Token budget for injection |
memoryRetentionDays |
integer | 30 (range 1–365) |
Retention window |
memoryStrategy |
enum | "hybrid" (one of recent, semantic, hybrid) |
Retrieval strategy |
skillsEnabled |
boolean | false |
Toggles per-key skill injection (see SKILLS.md) |
Note: the UI strategy "recent" maps to the internal "exact" retrieval
strategy via toMemoryRetrievalConfig() (chronological order).
Qdrant-related DB keys (qdrantEnabled, qdrantHost, qdrantPort,
qdrantApiKey, qdrantCollection default "omniroute_memory",
qdrantEmbeddingModel default "openai/text-embedding-3-small") are read by
normalizeQdrantConfig() in qdrant.ts.
No MEMORY_* or QDRANT_* env vars exist today — everything is per-instance
DB settings. OMNIROUTE_MEMORY_MB (commented out in .env.example) is
unrelated and refers to Node heap sizing.
Summarisation (summarization.ts)
summarizeMemories(apiKeyId, sessionId?, maxTokens = 4000) compacts older
content when the running token total over a key's memories exceeds the
budget. It iterates rows DESC by created_at, keeps rows that fit, and for
the rest replaces content in place with the first three sentences of the
original. tokensSaved is the difference in estimateTokens between old and
new content.
This routine is available but not called automatically in the current
chat pipeline — call it from a cron, an admin action, or
MemoryConfig.autoSummarize glue if you need ongoing compaction. The data
loss is one-way: original text is overwritten.
REST API
All endpoints require management auth (requireManagementAuth).
| Method | Path | Description |
|---|---|---|
GET |
/api/memory |
Paginated list with filters: apiKeyId, type, sessionId, q, limit, page, offset. Response includes stats.total and stats.byType |
POST |
/api/memory |
Create entry (Zod-validated: content, key, optional type, sessionId, apiKeyId, metadata, expiresAt). Calls createMemory() which upserts on (apiKeyId, key) |
GET |
/api/memory/[id] |
Fetch a single entry by UUID |
DELETE |
/api/memory/[id] |
Delete an entry; returns 404 when missing |
GET |
/api/memory/health |
Runs verifyExtractionPipeline("health-check") — round-trip create→list→delete to confirm the store is alive. Returns {working, latencyMs, error?} |
GET |
/api/settings/memory |
Current normalised MemorySettings |
PUT |
/api/settings/memory |
Update one or more of enabled, maxTokens, retentionDays, strategy, skillsEnabled |
The /api/memory list query supports either page-based pagination
(parsePaginationParams) or raw offset — when offset is present it
takes precedence and a derived page is computed for the response shape.
MCP Tools (open-sse/mcp-server/tools/memoryTools.ts)
When the MCP server is enabled, three memory tools are registered:
omniroute_memory_search—{apiKeyId, query?, type?, maxTokens?, limit?}→ wrapsretrieveMemories()withretrievalStrategy: "exact", optionally filters bytype, and reportstotalTokens.omniroute_memory_add—{apiKeyId, sessionId?, type, key, content, metadata?}→ wrapscreateMemory().omniroute_memory_clear—{apiKeyId, type?, olderThan?}→ lists matching entries, optionally filters by created-before timestamp, then deletes each viadeleteMemory().
See MCP-SERVER.md for transport and scope details.
Dashboard
src/app/(dashboard)/dashboard/memory/page.tsx provides:
- Real-time list, search, and pagination (debounced 300 ms).
- Type filter (
factual/episodic/procedural/semantic/ all). - Add-memory modal (key, content, type).
- Delete per row.
- JSON export of the current page; JSON import via file picker.
- A green/red health dot driven by
GET /api/memory/health. - Stat cards:
totalEntries,tokensUsed,hitRate(the latter two come from the API stats payload).
Memory and Qdrant settings live under
/dashboard/settings → Memory & Skills (MemorySkillsTab.tsx).
Caching
src/lib/memory/store.ts keeps an in-process LRU-ish cache
(MEMORY_CACHE_TTL = 5 min, MEMORY_MAX_CACHE_SIZE = 10 000, with 20 %
oldest eviction) for getMemory(id) reads, plus a generic key/value
memoryCache layer (src/lib/memory/cache.ts) with get/set/invalidate
methods used by callers that want their own scoped cache (1 000-entry LRU,
default TTL 5 min).
Privacy & Lifecycle
- Memory ownership is the API key id (
resolveMemoryOwnerIdinchatCore.ts). Without anapiKeyInfo.idneither retrieval nor injection nor extraction runs. - Entries with a future
expires_atare filtered out of retrieval; old entries beyondretentionDaysare excluded by thecreated_at >= cutoffclause inretrieveMemories. - For hard deletion, use
DELETE /api/memory/[id]oromniroute_memory_clear. - Extraction is fire-and-forget via
setImmediate; failures are logged undermemory.extraction.background.failedand never surface to the caller. - Verification round-trips (
verifyExtractionPipeline) clean up their own test entries in afinallyblock.
See Also
- SKILLS.md — the
skillsEnabledsetting injects tool definitions alongside memory. - MCP-SERVER.md — MCP transport / scopes.
- API_REFERENCE.md — broader API surface.
- Tuto_Qdrant.md — repository-root Qdrant setup tutorial (integration currently dormant — see status banner at top of that file).
- Source modules:
src/lib/memory/types.ts,schemas.tssrc/lib/memory/store.ts,retrieval.ts,injection.tssrc/lib/memory/extraction.ts,summarization.ts,verify.tssrc/lib/memory/settings.ts,qdrant.ts,cache.tssrc/lib/db/migrations/015_create_memories.sql,022_add_memory_fts5.sql,023_fix_memory_fts_uuid.sqlsrc/app/api/memory/route.ts,[id]/route.ts,health/route.tssrc/app/api/settings/memory/route.tsopen-sse/handlers/chatCore.ts(injection / extraction wiring)open-sse/mcp-server/tools/memoryTools.ts