Files
OmniRoute/src/lib/memory/retrieval.ts
diegosouzapw feb0e983eb fix(memory): activate semantic + hybrid + Qdrant tier-2 in chat hot path
Code review of plan 21 found two functional gaps:

FAIL #1 — toMemoryRetrievalConfig never forwarded the user query, so the
gate `if (config.query && useModernTable)` in retrieval.ts was always
false in the chat hot path. semantic/hybrid silently fell back to
"ORDER BY created_at DESC LIMIT 100", the pre-plan-21 behaviour.
sqlite-vec + RRF only ran in the Playground (retrievePreview, which
takes `query` positionally).

FAIL #2 — Bug #1 was not closed: searchSemanticMemory (Qdrant) was only
imported by /api/settings/qdrant/search, never by retrieval. With
vectorStore="qdrant", engineStatus reported backend="qdrant" but
retrieveMemories/retrievePreview kept using sqlite-vec — the status
diverged from the actual search path.

- settings.ts: toMemoryRetrievalConfig(settings, { query? }) accepts
  and forwards the query.
- chatCore.ts: extracts the last user message from body.messages or
  body.input (Chat + Responses APIs).
- retrieval.ts: adds a Qdrant branch in case "semantic", case "hybrid"
  and retrievePreview; falls through to sqlite-vec on failure or empty
  results so the §7 "degrades to sqlite-vec / FTS5" contract holds.
- engineStatus only reports backend="qdrant" when the user opted in
  (settings.vectorStore === "qdrant") and Qdrant is healthy.
- memories[] tier union includes "qdrant".

331 memory unit tests pass; typecheck:core / typecheck:noimplicit:core /
check:cycles clean.
2026-05-28 21:46:54 -03:00

1171 lines
40 KiB
TypeScript

import { getDbInstance } from "../db/core";
import { Memory, MemoryConfig, MemoryType } from "./types";
import { MemoryConfigSchema } from "./schemas";
import { logger } from "../../../open-sse/utils/logger.ts";
import { sanitizeErrorMessage } from "../../../open-sse/utils/error.ts";
import { resolveEmbeddingSource, embed } from "./embedding";
import { getVectorStore } from "./vectorStore";
import { getMemorySettings } from "./settings";
import { stats as embeddingCacheStats } from "./embedding/cache";
import { getQdrantConfig, checkQdrantHealth, searchSemanticMemory } from "./qdrant";
import type { MemoryEngineStatus } from "@/shared/schemas/memory";
const log = logger("MEMORY_RETRIEVAL");
interface MemoryRow {
id: string;
api_key_id?: string;
apiKeyId?: string;
session_id?: string | null;
sessionId?: string | null;
type: MemoryType;
key?: string | null;
content: string;
metadata?: string | null;
created_at?: string;
createdAt?: string;
updated_at?: string;
updatedAt?: string;
expires_at?: string | null;
expiresAt?: string | null;
}
interface RetrievalOptions extends Partial<MemoryConfig> {
query?: string;
sessionId?: string;
}
// ──────────────── Types exposed publicly (§3.6) ────────────────
export interface RetrievePreviewItem {
memory: Memory;
score: number;
tokens: number;
tier: "fts5" | "vector" | "hybrid-rrf" | "qdrant";
vecScore: number | null;
ftsScore: number | null;
}
export interface RetrievePreviewResolution {
embeddingSource: "remote" | "static" | "transformers" | null;
embeddingModel: string | null;
vectorStore: "sqlite-vec" | "qdrant" | "none";
strategyUsed: "exact" | "semantic" | "hybrid";
rerankApplied: boolean;
fallbackReason: string | null;
}
export interface RetrievePreviewBundle {
items: RetrievePreviewItem[];
resolution: RetrievePreviewResolution;
totalTokens: number;
budgetMaxTokens: number;
}
// ──────────────── Helpers ────────────────
/**
* Simple token estimation function (roughly 1 token per 4 characters)
*/
export function estimateTokens(text: string): number {
if (!text || typeof text !== "string") return 0;
return Math.ceil(text.length / 4);
}
function hasTable(tableName: string): boolean {
const db = getDbInstance();
const row = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(tableName) as { name?: string } | undefined;
return row?.name === tableName;
}
function parseMetadata(raw: unknown): Record<string, unknown> {
if (!raw || typeof raw !== "string") return {};
try {
const parsed = JSON.parse(raw);
return typeof parsed === "object" && parsed !== null ? parsed : {};
} catch {
return {};
}
}
function rowToMemory(row: MemoryRow): Memory {
const createdAt = row.created_at || row.createdAt || new Date().toISOString();
const updatedAt = row.updated_at || row.updatedAt || createdAt;
const expiresAt = row.expires_at ?? row.expiresAt ?? null;
return {
id: String(row.id),
apiKeyId: String(row.api_key_id || row.apiKeyId || ""),
sessionId: String(row.session_id ?? row.sessionId ?? ""),
type: row.type as MemoryType,
key: String(row.key || ""),
content: String(row.content || ""),
metadata: parseMetadata(row.metadata),
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
expiresAt: expiresAt ? new Date(String(expiresAt)) : null,
};
}
/**
* Score a memory against a query using simple string matching (no dynamic RegExp).
* Uses indexOf() for full-phrase matches and split-token substring checks only,
* so there is no ReDoS risk — no user input is passed to RegExp().
*/
function getRelevanceScore(memory: Memory, query: string): number {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return 0;
const haystacks = [
memory.content.toLowerCase(),
memory.key.toLowerCase(),
JSON.stringify(memory.metadata).toLowerCase(),
];
const tokens = normalizedQuery.split(/\s+/).filter(Boolean);
let score = 0;
for (const haystack of haystacks) {
// Full phrase match (safe: literal string, not regex)
if (haystack.includes(normalizedQuery)) {
score += 20;
}
for (const token of tokens) {
if (!token) continue;
// Token-level substring count using indexOf loop (no RegExp on user input)
if (haystack === memory.key.toLowerCase() && haystack.includes(token)) {
score += 6;
continue;
}
// Count occurrences via indexOf loop — avoids new RegExp(token)
let pos = 0;
let matchCount = 0;
while ((pos = haystack.indexOf(token, pos)) !== -1) {
matchCount++;
pos += token.length;
}
score += matchCount * 3;
}
}
return score;
}
/**
* Fetch memories from SQLite by an array of IDs, preserving order.
*/
function fetchMemoriesByIds(ids: string[]): Memory[] {
if (ids.length === 0) return [];
const db = getDbInstance();
const placeholders = ids.map(() => "?").join(", ");
const rows = db
.prepare(`SELECT * FROM memories WHERE id IN (${placeholders})`)
.all(...ids) as MemoryRow[];
const byId = new Map<string, Memory>();
for (const row of rows) {
byId.set(String(row.id), rowToMemory(row));
}
return ids.map((id) => byId.get(id)).filter((m): m is Memory => m !== undefined);
}
interface FtsColConfig {
apiKeyCol: string;
expiresCol: string;
createdCol: string;
sessionCol: string;
tableName: string;
query?: string;
scope?: string;
sessionId?: string;
retentionDays?: number;
}
/**
* Build the FTS5 rows for a given apiKeyId + config + query.
* Returns MemoryRow array (or falls back to empty on error).
*/
function buildFtsRows(apiKeyId: string, config: FtsColConfig): MemoryRow[] {
if (!config.query) return [];
const db = getDbInstance();
const {
apiKeyCol,
expiresCol,
createdCol,
sessionCol,
tableName,
query: q,
scope,
sessionId,
retentionDays,
} = config;
let ftsQueryStr =
`SELECT m.* FROM ${tableName} m ` +
`JOIN memory_fts f ON m.memory_id = f.rowid ` +
`WHERE f.memory_fts MATCH ? AND m.${apiKeyCol} = ? ` +
`AND (m.${expiresCol} IS NULL OR datetime(m.${expiresCol}) > datetime('now'))`;
if (scope === "session" && sessionId) {
ftsQueryStr += ` AND m.${sessionCol} = ?`;
}
if (retentionDays && retentionDays > 0) {
ftsQueryStr += ` AND datetime(m.${createdCol}) >= datetime(?)`;
}
ftsQueryStr += ` ORDER BY f.rank LIMIT 100`;
const ftsParams: unknown[] = [q, apiKeyId];
if (scope === "session" && sessionId) ftsParams.push(sessionId);
if (retentionDays && retentionDays > 0) {
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
ftsParams.push(cutoff);
}
try {
return db.prepare(ftsQueryStr).all(...ftsParams) as MemoryRow[];
} catch {
return [];
}
}
// Loopback rerank URL — localhost only, never routed over the network.
// nosemgrep: javascript.lang.security.audit.non-literal-regexp.non-literal-regexp
const RERANK_LOOPBACK_URL = "http://127.0.0.1:20128/v1/rerank";
/**
* Apply reranking via /v1/rerank (loopback-only) if rerankEnabled + rerankProviderModel is set.
* Returns reordered array (or original order on any error — rerank failure never fails retrieval).
*
* Security note: the URL is a hardcoded loopback address (127.0.0.1:20128) — it never
* carries sensitive data over a network link. HTTP is safe for loopback-only IPC.
* nosemgrep: javascript.lang.security.detect-non-literal-url
*/
async function applyRerank<T extends { memory: Memory; score: number }>(
items: T[],
query: string,
rerankProviderModel: string
): Promise<T[]> {
if (items.length === 0) return items;
try {
const documents = items.map((item) => item.memory.content);
const body = {
model: rerankProviderModel,
query,
documents,
top_n: items.length,
};
const res = await fetch(RERANK_LOOPBACK_URL, { // nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
log.warn("memory.rerank.http_fail", {
status: res.status,
model: rerankProviderModel,
});
return items;
}
const data = (await res.json()) as {
results?: Array<{ index: number; relevance_score: number }>;
};
if (!Array.isArray(data.results) || data.results.length === 0) {
return items;
}
// Build reordered list using the index references from the rerank response
const reordered: T[] = [];
for (const r of data.results) {
const idx = r.index;
if (typeof idx === "number" && idx >= 0 && idx < items.length) {
const item = items[idx];
if (item) reordered.push({ ...item, score: r.relevance_score });
}
}
// Append any items not mentioned in results (safety net)
const mentionedIndices = new Set(data.results.map((r) => r.index));
for (let i = 0; i < items.length; i++) {
if (!mentionedIndices.has(i)) {
const item = items[i];
if (item) reordered.push(item);
}
}
return reordered;
} catch (err: unknown) {
log.warn("memory.rerank.error", {
error: sanitizeErrorMessage(err instanceof Error ? err.message : String(err)),
model: rerankProviderModel,
});
return items;
}
}
// ──────────────── Main retrieval function (hot path — signature PRESERVED) ────────────────
/**
* Retrieve memories with token budget enforcement.
* Signature PRESERVED: retrieveMemories(apiKeyId: string, config: RetrievalOptions = {})
* Hot path: open-sse/handlers/chatCore.ts calls this unchanged.
*/
export async function retrieveMemories(
apiKeyId: string,
config: RetrievalOptions = {}
): Promise<Memory[]> {
log.info("memory.retrieval.start", { apiKeyId, strategy: config.retrievalStrategy });
// Validate and normalize config
const normalizedConfig = MemoryConfigSchema.parse({
enabled: true,
maxTokens: 2000,
retrievalStrategy: "exact",
autoSummarize: false,
persistAcrossModels: false,
retentionDays: 30,
scope: "apiKey",
...config,
});
if (!normalizedConfig.enabled || normalizedConfig.maxTokens <= 0) {
return [];
}
const maxTokens = Math.min(Math.max(normalizedConfig.maxTokens, 1), 8000);
const strategy = normalizedConfig.retrievalStrategy;
const db = getDbInstance();
// Plan 21 FAIL #2 fix: include "qdrant" in the tier union so that the
// Qdrant tier-2 branch in semantic/hybrid below can push hits with that tier.
const memories: Array<{
memory: Memory;
score: number;
tier: "fts5" | "vector" | "hybrid-rrf" | "qdrant";
}> = [];
let totalTokens = 0;
const useModernTable = hasTable("memories");
const tableName = useModernTable ? "memories" : "memory";
const columns = useModernTable
? {
apiKeyId: "api_key_id",
sessionId: "session_id",
createdAt: "created_at",
expiresAt: "expires_at",
}
: {
apiKeyId: "apiKeyId",
sessionId: "sessionId",
createdAt: "createdAt",
expiresAt: "expiresAt",
};
// Build base query
let query =
`SELECT * FROM ${tableName} WHERE ${columns.apiKeyId} = ? ` +
`AND (${columns.expiresAt} IS NULL OR datetime(${columns.expiresAt}) > datetime('now'))`;
const params: unknown[] = [apiKeyId];
if (normalizedConfig.scope === "session" && config.sessionId) {
query += ` AND ${columns.sessionId} = ?`;
params.push(config.sessionId);
}
if (normalizedConfig.retentionDays > 0) {
const cutoff = new Date(
Date.now() - normalizedConfig.retentionDays * 24 * 60 * 60 * 1000
).toISOString();
query += ` AND datetime(${columns.createdAt}) >= datetime(?)`;
params.push(cutoff);
}
// Load extended settings for embedding/vector-store resolution
const settings = await getMemorySettings();
// Execute query based on strategy
let rows: MemoryRow[];
const ftsAvailable = useModernTable && hasTable("memory_fts");
const ftsColConfig: FtsColConfig = {
apiKeyCol: columns.apiKeyId,
expiresCol: columns.expiresAt,
createdCol: columns.createdAt,
sessionCol: columns.sessionId,
tableName,
query: config.query,
scope: normalizedConfig.scope,
sessionId: config.sessionId,
retentionDays: normalizedConfig.retentionDays,
};
switch (strategy) {
case "semantic": {
// Attempt vector search if embedding + vector store are available
if (config.query && useModernTable) {
const resolution = resolveEmbeddingSource(settings);
if (resolution.source !== null) {
// Plan 21 FAIL #2 fix (Bug #1): when the user opted into Qdrant
// (settings.vectorStore === "qdrant"), route the semantic search to
// Qdrant first. If Qdrant is unreachable or returns nothing, fall
// through to sqlite-vec — preserving the "degrades to sqlite-vec /
// FTS5" contract of §7.
if (settings.vectorStore === "qdrant") {
try {
const qres = await searchSemanticMemory(config.query, 100, {
apiKeyId,
});
if (qres.ok && qres.results && qres.results.length > 0) {
const hitIds = qres.results.map((r) => r.id);
const hitMemories = fetchMemoriesByIds(hitIds);
const scoreMap = new Map(
qres.results.map((r) => [r.id, r.score])
);
let qdrantItems = hitMemories.map((m) => ({
memory: m,
score: scoreMap.get(m.id) ?? 0,
tier: "qdrant" as const,
}));
if (
settings.rerankEnabled &&
settings.rerankProviderModel &&
config.query
) {
qdrantItems = (await applyRerank(
qdrantItems,
config.query,
settings.rerankProviderModel
)) as typeof qdrantItems;
}
for (const entry of qdrantItems) {
const memoryTokens = estimateTokens(entry.memory.content);
if (totalTokens + memoryTokens > maxTokens) {
if (memories.length === 0) {
memories.push(entry);
totalTokens += memoryTokens;
}
break;
}
memories.push(entry);
totalTokens += memoryTokens;
}
log.info("memory.retrieval.complete", {
apiKeyId,
count: memories.length,
tier: "qdrant",
});
return memories.map((e) => e.memory);
}
} catch (err: unknown) {
log.warn("memory.retrieval.qdrant.fail", {
error: sanitizeErrorMessage(
err instanceof Error ? err.message : String(err)
),
});
// fall through to sqlite-vec degradation
}
}
const embeddingResult = await embed(config.query, settings);
if ("vector" in embeddingResult) {
const vec = getVectorStore();
if (vec) {
try {
await vec.ensureReady(resolution);
const hits = await vec.searchVector(embeddingResult.vector, 100, apiKeyId);
const hitIds = hits.map((h) => h.memoryId);
const hitMemories = fetchMemoriesByIds(hitIds);
const scoreMap = new Map(hits.map((h) => [h.memoryId, h.score]));
let rankedItems = hitMemories.map((m) => ({
memory: m,
score: scoreMap.get(m.id) ?? 0,
tier: "vector" as const,
}));
// Apply rerank if enabled
if (settings.rerankEnabled && settings.rerankProviderModel && config.query) {
rankedItems = (await applyRerank(
rankedItems,
config.query,
settings.rerankProviderModel
)) as typeof rankedItems;
}
// Token budget enforcement
for (const entry of rankedItems) {
const memoryTokens = estimateTokens(entry.memory.content);
if (totalTokens + memoryTokens > maxTokens) {
if (memories.length === 0) {
memories.push(entry);
totalTokens += memoryTokens;
}
break;
}
memories.push(entry);
totalTokens += memoryTokens;
}
log.info("memory.retrieval.complete", {
apiKeyId,
count: memories.length,
tier: "vector",
});
return memories.map((e) => e.memory);
} catch (err: unknown) {
log.warn("memory.retrieval.vector.fail", {
error: sanitizeErrorMessage(err instanceof Error ? err.message : String(err)),
});
// Fall through to FTS5 degradation
}
}
}
}
}
// Degraded path: FTS5 keyword
if (config.query && ftsAvailable) {
rows = buildFtsRows(apiKeyId, ftsColConfig);
if (rows.length === 0) {
query += ` ORDER BY ${columns.createdAt} DESC LIMIT 100`;
rows = db.prepare(query).all(...params) as MemoryRow[];
}
} else {
query += ` ORDER BY ${columns.createdAt} DESC LIMIT 100`;
rows = db.prepare(query).all(...params) as MemoryRow[];
}
break;
}
case "hybrid": {
// Attempt hybrid vector+FTS5 search if embedding + vector store are available
if (config.query && useModernTable) {
const resolution = resolveEmbeddingSource(settings);
if (resolution.source !== null) {
// Plan 21 FAIL #2 fix: tier-2 Qdrant route also covers hybrid strategy.
// Qdrant is vector-only (no FTS5 fusion), so this acts as the vector
// half of the hybrid contract. If Qdrant is down or empty, fall through
// to sqlite-vec's hybrid RRF (FTS5 + vector).
if (settings.vectorStore === "qdrant") {
try {
const qres = await searchSemanticMemory(config.query, 100, {
apiKeyId,
});
if (qres.ok && qres.results && qres.results.length > 0) {
const hitIds = qres.results.map((r) => r.id);
const hitMemories = fetchMemoriesByIds(hitIds);
const scoreMap = new Map(
qres.results.map((r) => [r.id, r.score])
);
let qdrantItems = hitMemories.map((m) => ({
memory: m,
score: scoreMap.get(m.id) ?? 0,
tier: "qdrant" as const,
}));
if (
settings.rerankEnabled &&
settings.rerankProviderModel &&
config.query
) {
qdrantItems = (await applyRerank(
qdrantItems,
config.query,
settings.rerankProviderModel
)) as typeof qdrantItems;
}
for (const entry of qdrantItems) {
const memoryTokens = estimateTokens(entry.memory.content);
if (totalTokens + memoryTokens > maxTokens) {
if (memories.length === 0) {
memories.push(entry);
totalTokens += memoryTokens;
}
break;
}
memories.push(entry);
totalTokens += memoryTokens;
}
log.info("memory.retrieval.complete", {
apiKeyId,
count: memories.length,
tier: "qdrant",
});
return memories.map((e) => e.memory);
}
} catch (err: unknown) {
log.warn("memory.retrieval.qdrant.fail", {
error: sanitizeErrorMessage(
err instanceof Error ? err.message : String(err)
),
});
// fall through to sqlite-vec hybrid RRF
}
}
const embeddingResult = await embed(config.query, settings);
if ("vector" in embeddingResult) {
const vec = getVectorStore();
if (vec) {
try {
await vec.ensureReady(resolution);
const hybridHits = await vec.searchHybrid(
embeddingResult.vector,
config.query,
100,
apiKeyId
);
const hitIds = hybridHits.map((h) => h.memoryId);
const hitMemories = fetchMemoriesByIds(hitIds);
const scoreMap = new Map(
hybridHits.map((h) => [
h.memoryId,
{ rrfScore: h.rrfScore, vecDistance: h.vecDistance, ftsScore: h.ftsScore },
])
);
let rankedHybridItems = hitMemories.map((m) => {
const sc = scoreMap.get(m.id);
return {
memory: m,
score: sc?.rrfScore ?? 0,
tier: "hybrid-rrf" as const,
};
});
// Apply rerank if enabled
if (settings.rerankEnabled && settings.rerankProviderModel && config.query) {
rankedHybridItems = (await applyRerank(
rankedHybridItems,
config.query,
settings.rerankProviderModel
)) as typeof rankedHybridItems;
}
// Token budget enforcement
for (const entry of rankedHybridItems) {
const memoryTokens = estimateTokens(entry.memory.content);
if (totalTokens + memoryTokens > maxTokens) {
if (memories.length === 0) {
memories.push(entry);
totalTokens += memoryTokens;
}
break;
}
memories.push(entry);
totalTokens += memoryTokens;
}
log.info("memory.retrieval.complete", {
apiKeyId,
count: memories.length,
tier: "hybrid-rrf",
});
return memories.map((e) => e.memory);
} catch (err: unknown) {
log.warn("memory.retrieval.hybrid.fail", {
error: sanitizeErrorMessage(err instanceof Error ? err.message : String(err)),
});
// Fall through to FTS5 degradation
}
}
}
}
}
// Degraded path: FTS5 + keyword union
let ftsRows: MemoryRow[] = [];
if (config.query && ftsAvailable) {
ftsRows = buildFtsRows(apiKeyId, ftsColConfig);
}
// Get chronological results for keyword scoring
query += ` ORDER BY ${columns.createdAt} DESC LIMIT 100`;
const keywordRows = db.prepare(query).all(...params) as MemoryRow[];
// Union: FTS5 results first (higher relevance), then keyword results, dedup by id
const seen = new Set<string>();
rows = [];
for (const row of [...ftsRows, ...keywordRows]) {
const rowId = String(row.id);
if (!seen.has(rowId)) {
seen.add(rowId);
rows.push(row);
}
}
break;
}
case "exact":
default: {
query += ` ORDER BY ${columns.createdAt} DESC LIMIT 100`;
rows = db.prepare(query).all(...params) as MemoryRow[];
}
}
const rankedRows = rows
.map((row) => {
const memory = rowToMemory(row);
const score = config.query ? getRelevanceScore(memory, config.query) : 0;
return { memory, score, tier: "fts5" as const };
})
.filter((entry) => !config.query || entry.score > 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return b.memory.createdAt.getTime() - a.memory.createdAt.getTime();
});
// Process memories until budget exceeded
for (const entry of rankedRows) {
const memory = entry.memory;
const memoryTokens = estimateTokens(memory.content);
if (totalTokens + memoryTokens > maxTokens) {
if (memories.length === 0) {
memories.push(entry);
totalTokens += memoryTokens;
}
break;
}
memories.push(entry);
totalTokens += memoryTokens;
}
const result = memories.map((entry) => entry.memory);
log.info("memory.retrieval.complete", { apiKeyId, count: result.length });
log.debug("memory.retrieval.selected", { ids: result.map((m) => m.id) });
return result;
}
// ──────────────── retrievePreview (§3.6 — dry-run for Playground) ────────────────
/**
* Dry-run of retrieveMemories.
* Returns the full bundle (items + resolution metadata) WITHOUT injecting into chat.
* If apiKeyId is null, tests against all memories (global scope).
*/
export async function retrievePreview(
apiKeyId: string | null,
query: string,
options: { strategy: "exact" | "semantic" | "hybrid"; maxTokens: number; limit: number }
): Promise<RetrievePreviewBundle> {
const { strategy, maxTokens, limit } = options;
const settings = await getMemorySettings();
const resolution = resolveEmbeddingSource(settings);
let fallbackReason: string | null = null;
let rerankApplied = false;
const result: RetrievePreviewItem[] = [];
let totalTokens = 0;
const useModernTable = hasTable("memories");
const ftsAvailable = useModernTable && hasTable("memory_fts");
const db = getDbInstance();
const tableName = useModernTable ? "memories" : "memory";
const apiKeyCol = useModernTable ? "api_key_id" : "apiKeyId";
const expiresCol = useModernTable ? "expires_at" : "expiresAt";
const createdCol = useModernTable ? "created_at" : "createdAt";
// Determine vector store backend
let vectorStoreBackend: "sqlite-vec" | "qdrant" | "none" = "none";
const vec = getVectorStore();
if (vec) vectorStoreBackend = "sqlite-vec";
if (strategy === "semantic" || strategy === "hybrid") {
if (resolution.source !== null && query) {
// Plan 21 FAIL #2 fix: when settings.vectorStore === "qdrant",
// the Playground must show what production retrieval would actually
// see — Qdrant tier-2. Falls through to sqlite-vec on failure.
if (settings.vectorStore === "qdrant") {
try {
const qres = await searchSemanticMemory(
query,
limit,
apiKeyId ? { apiKeyId } : undefined
);
if (qres.ok && qres.results && qres.results.length > 0) {
const hitIds = qres.results.map((r) => r.id);
const hitMemories = fetchMemoriesByIds(hitIds).slice(0, limit);
const scoreMap = new Map(
qres.results.map((r) => [r.id, r.score])
);
let items: Array<{
memory: Memory;
score: number;
tier: "qdrant";
vecScore: number | null;
ftsScore: null;
}> = hitMemories.map((m) => ({
memory: m,
score: scoreMap.get(m.id) ?? 0,
tier: "qdrant" as const,
vecScore: scoreMap.get(m.id) ?? null,
ftsScore: null,
}));
if (settings.rerankEnabled && settings.rerankProviderModel) {
items = (await applyRerank(
items,
query,
settings.rerankProviderModel
)) as typeof items;
rerankApplied = true;
}
for (const item of items) {
if (result.length >= limit) break;
const tokens = estimateTokens(item.memory.content);
if (totalTokens + tokens > maxTokens && result.length > 0) break;
result.push({ ...item, tokens });
totalTokens += tokens;
}
return {
items: result,
resolution: {
embeddingSource: resolution.source,
embeddingModel: resolution.model,
vectorStore: "qdrant",
strategyUsed: strategy,
rerankApplied,
fallbackReason: null,
},
totalTokens,
budgetMaxTokens: maxTokens,
};
}
// Qdrant returned nothing — fall through to sqlite-vec for parity
// with production (so the Playground reflects the same fallback).
fallbackReason = "Qdrant retornou 0 resultados — fallback p/ sqlite-vec";
} catch (err: unknown) {
fallbackReason = sanitizeErrorMessage(
err instanceof Error ? err.message : String(err)
);
log.warn("memory.preview.qdrant.fail", { error: fallbackReason });
}
}
const embeddingResult = await embed(query, settings);
if ("vector" in embeddingResult) {
if (vec) {
try {
await vec.ensureReady(resolution);
if (strategy === "semantic") {
const hits = await vec.searchVector(
embeddingResult.vector,
limit,
apiKeyId ?? undefined
);
const hitIds = hits.map((h) => h.memoryId);
const hitMemories = fetchMemoriesByIds(hitIds).slice(0, limit);
const scoreMap = new Map(hits.map((h) => [h.memoryId, h.score]));
let items: Array<{
memory: Memory;
score: number;
tier: "vector";
vecScore: number | null;
ftsScore: null;
}> = hitMemories.map((m) => ({
memory: m,
score: scoreMap.get(m.id) ?? 0,
tier: "vector" as const,
vecScore: scoreMap.get(m.id) ?? null,
ftsScore: null,
}));
if (settings.rerankEnabled && settings.rerankProviderModel) {
items = (await applyRerank(
items,
query,
settings.rerankProviderModel
)) as typeof items;
rerankApplied = true;
}
for (const item of items) {
if (result.length >= limit) break;
const tokens = estimateTokens(item.memory.content);
if (totalTokens + tokens > maxTokens && result.length > 0) break;
result.push({ ...item, tokens });
totalTokens += tokens;
}
} else {
// hybrid
const hybridHits = await vec.searchHybrid(
embeddingResult.vector,
query,
limit,
apiKeyId ?? undefined
);
const hitIds = hybridHits.map((h) => h.memoryId);
const hitMemories = fetchMemoriesByIds(hitIds);
const scoreMap = new Map(
hybridHits.map((h) => [
h.memoryId,
{
rrfScore: h.rrfScore,
vecDistance: h.vecDistance,
ftsScore: h.ftsScore,
},
])
);
let items = hitMemories.slice(0, limit).map((m) => {
const sc = scoreMap.get(m.id);
return {
memory: m,
score: sc?.rrfScore ?? 0,
tier: "hybrid-rrf" as const,
vecScore: sc?.vecDistance != null ? 1 / (1 + sc.vecDistance) : null,
ftsScore: sc?.ftsScore ?? null,
};
});
if (settings.rerankEnabled && settings.rerankProviderModel) {
items = (await applyRerank(
items,
query,
settings.rerankProviderModel
)) as typeof items;
rerankApplied = true;
}
for (const item of items) {
if (result.length >= limit) break;
const tokens = estimateTokens(item.memory.content);
if (totalTokens + tokens > maxTokens && result.length > 0) break;
result.push({ ...item, tokens });
totalTokens += tokens;
}
}
return {
items: result,
resolution: {
embeddingSource: resolution.source,
embeddingModel: resolution.model,
vectorStore: vectorStoreBackend,
strategyUsed: strategy,
rerankApplied,
fallbackReason: null,
},
totalTokens,
budgetMaxTokens: maxTokens,
};
} catch (err: unknown) {
fallbackReason = sanitizeErrorMessage(
err instanceof Error ? err.message : String(err)
);
log.warn("memory.preview.vector.fail", { error: fallbackReason });
}
} else {
fallbackReason = "sqlite-vec não disponível (degradado para FTS5)";
}
} else {
// EmbeddingError
fallbackReason =
"message" in embeddingResult ? (embeddingResult.message as string) : "embedding falhou";
}
} else if (!query) {
fallbackReason = "query vazia — usando FTS5";
} else {
fallbackReason = resolution.reason;
}
}
// FTS5 fallback path (or strategy=exact)
let baseQuery = `SELECT * FROM ${tableName}`;
const baseParams: unknown[] = [];
if (apiKeyId) {
baseQuery += ` WHERE ${apiKeyCol} = ?`;
baseParams.push(apiKeyId);
baseQuery += ` AND (${expiresCol} IS NULL OR datetime(${expiresCol}) > datetime('now'))`;
} else {
baseQuery += ` WHERE (${expiresCol} IS NULL OR datetime(${expiresCol}) > datetime('now'))`;
}
if (strategy === "exact") {
baseQuery += ` ORDER BY ${createdCol} DESC LIMIT ?`;
baseParams.push(limit);
const rows = db.prepare(baseQuery).all(...baseParams) as MemoryRow[];
for (const row of rows) {
if (result.length >= limit) break;
const memory = rowToMemory(row);
const score = query ? getRelevanceScore(memory, query) : 0;
const tokens = estimateTokens(memory.content);
if (totalTokens + tokens > maxTokens && result.length > 0) break;
result.push({ memory, score, tokens, tier: "fts5", vecScore: null, ftsScore: null });
totalTokens += tokens;
}
} else {
// Semantic/hybrid degraded to FTS5
let ftsRows: MemoryRow[] = [];
if (query && ftsAvailable) {
const ftsQueryStr = apiKeyId
? `SELECT m.* FROM ${tableName} m JOIN memory_fts f ON m.memory_id = f.rowid WHERE f.memory_fts MATCH ? AND m.${apiKeyCol} = ? ORDER BY f.rank LIMIT ?`
: `SELECT m.* FROM ${tableName} m JOIN memory_fts f ON m.memory_id = f.rowid WHERE f.memory_fts MATCH ? ORDER BY f.rank LIMIT ?`;
const ftsP: unknown[] = apiKeyId ? [query, apiKeyId, limit] : [query, limit];
try {
ftsRows = db.prepare(ftsQueryStr).all(...ftsP) as MemoryRow[];
} catch {
ftsRows = [];
}
}
if (ftsRows.length === 0) {
baseQuery += ` ORDER BY ${createdCol} DESC LIMIT ?`;
baseParams.push(limit);
ftsRows = db.prepare(baseQuery).all(...baseParams) as MemoryRow[];
}
for (const row of ftsRows) {
if (result.length >= limit) break;
const memory = rowToMemory(row);
const score = query ? getRelevanceScore(memory, query) : 0;
const tokens = estimateTokens(memory.content);
if (totalTokens + tokens > maxTokens && result.length > 0) break;
result.push({ memory, score, tokens, tier: "fts5", vecScore: null, ftsScore: null });
totalTokens += tokens;
}
}
return {
items: result,
resolution: {
embeddingSource: resolution.source,
embeddingModel: resolution.model,
vectorStore: vectorStoreBackend,
strategyUsed: strategy,
rerankApplied,
fallbackReason,
},
totalTokens,
budgetMaxTokens: maxTokens,
};
}
// ──────────────── engineStatus (§3.2) ────────────────
/**
* Returns the current status of the memory engine (for the Engine tab in the UI).
* Matches MemoryEngineStatusSchema from @/shared/schemas/memory.
*/
export async function engineStatus(): Promise<MemoryEngineStatus> {
const settings = await getMemorySettings();
const resolution = resolveEmbeddingSource(settings);
const cacheStats = embeddingCacheStats();
// Vector store
const vec = getVectorStore();
let vecBackend: "sqlite-vec" | "qdrant" | "none" = "none";
let vecAvailable = false;
let vecRowCount = 0;
let vecNeedsReindex = 0;
let vecReason = "sqlite-vec não disponível";
if (vec) {
vecBackend = "sqlite-vec";
vecAvailable = true;
try {
const s = await vec.stats();
vecRowCount = s.rowCount;
vecNeedsReindex = s.needsReindex;
vecReason = `sqlite-vec ativo, dim=${s.activeDim ?? "null"}`;
} catch {
vecReason = "sqlite-vec ativo mas stats falharam";
}
} else {
vecReason = "sqlite-vec não disponível — usando apenas FTS5";
}
// Qdrant
let qdrantEnabled = false;
let qdrantHealthy: boolean | null = null;
let qdrantLatencyMs: number | null = null;
let qdrantError: string | null = null;
try {
const qdrantCfg = await getQdrantConfig();
qdrantEnabled = qdrantCfg.enabled;
if (qdrantEnabled) {
const health = await checkQdrantHealth();
qdrantHealthy = health.ok;
qdrantLatencyMs = health.latencyMs;
qdrantError = health.error ? sanitizeErrorMessage(health.error) : null;
// Plan 21 FAIL #2 fix: only claim vectorStore=qdrant when the user
// explicitly opted into it (settings.vectorStore === "qdrant"). Before,
// the status reported "qdrant" whenever the cluster was healthy even
// though the retrieval path always used sqlite-vec — engineStatus lied.
if (qdrantHealthy && settings.vectorStore === "qdrant") {
vecBackend = "qdrant";
vecAvailable = true;
vecReason = `Qdrant configurado em ${qdrantCfg.host}:${qdrantCfg.port}`;
}
}
} catch (err: unknown) {
qdrantError = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
}
// Rerank
let rerankAvailable = false;
let rerankReason = "rerank desabilitado";
if (settings.rerankEnabled && settings.rerankProviderModel) {
rerankAvailable = true;
rerankReason = `rerank ativo: ${settings.rerankProviderModel}`;
} else if (settings.rerankEnabled && !settings.rerankProviderModel) {
rerankReason = "rerank habilitado mas provider não configurado";
}
const rerankParts = settings.rerankProviderModel?.split("/") ?? [];
const rerankProvider = rerankParts.length >= 2 ? (rerankParts[0] ?? null) : null;
const rerankModel =
rerankParts.length >= 2
? rerankParts.slice(1).join("/")
: (settings.rerankProviderModel ?? null);
return {
keyword: { available: true, backend: "FTS5" },
embedding: {
source: resolution.source,
model: resolution.model,
dimensions: resolution.dimensions,
available: resolution.source !== null,
reason: resolution.reason,
cacheStats,
},
vectorStore: {
backend: vecBackend,
available: vecAvailable,
rowCount: vecRowCount,
needsReindex: vecNeedsReindex,
reason: vecReason,
},
qdrant: {
enabled: qdrantEnabled,
healthy: qdrantHealthy,
latencyMs: qdrantLatencyMs,
error: qdrantError,
},
rerank: {
enabled: settings.rerankEnabled,
provider: rerankProvider,
model: rerankModel,
available: rerankAvailable,
reason: rerankReason,
},
};
}