| 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;
|
| }
|
|
|
|
|
|
|
| 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;
|
| }
|
|
|
|
|
|
|
| |
| |
|
|
| 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,
|
| };
|
| }
|
|
|
| |
| |
| |
| |
|
|
| 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) {
|
|
|
| if (haystack.includes(normalizedQuery)) {
|
| score += 20;
|
| }
|
|
|
| for (const token of tokens) {
|
| if (!token) continue;
|
|
|
| if (haystack === memory.key.toLowerCase() && haystack.includes(token)) {
|
| score += 6;
|
| continue;
|
| }
|
|
|
| let pos = 0;
|
| let matchCount = 0;
|
| while ((pos = haystack.indexOf(token, pos)) !== -1) {
|
| matchCount++;
|
| pos += token.length;
|
| }
|
| score += matchCount * 3;
|
| }
|
| }
|
|
|
| return score;
|
| }
|
|
|
| |
| |
|
|
| 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;
|
| }
|
|
|
| |
| |
| |
|
|
| 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 [];
|
| }
|
| }
|
|
|
|
|
|
|
| const RERANK_LOOPBACK_URL = "http://127.0.0.1:20128/v1/rerank";
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| 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, {
|
| 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;
|
| }
|
|
|
|
|
| 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 });
|
| }
|
| }
|
|
|
| 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;
|
| }
|
| }
|
|
|
|
|
|
|
| |
| |
| |
| |
|
|
| export async function retrieveMemories(
|
| apiKeyId: string,
|
| config: RetrievalOptions = {}
|
| ): Promise<Memory[]> {
|
| log.info("memory.retrieval.start", { apiKeyId, strategy: config.retrievalStrategy });
|
|
|
|
|
| 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();
|
|
|
|
|
| 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",
|
| };
|
|
|
|
|
| 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);
|
| }
|
|
|
|
|
| const settings = await getMemorySettings();
|
|
|
|
|
| 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": {
|
|
|
| if (config.query && useModernTable) {
|
| const resolution = resolveEmbeddingSource(settings);
|
| if (resolution.source !== null) {
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
| ),
|
| });
|
|
|
| }
|
| }
|
|
|
| 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,
|
| }));
|
|
|
|
|
| if (settings.rerankEnabled && settings.rerankProviderModel && config.query) {
|
| rankedItems = (await applyRerank(
|
| rankedItems,
|
| config.query,
|
| settings.rerankProviderModel
|
| )) as typeof rankedItems;
|
| }
|
|
|
|
|
| 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)),
|
| });
|
|
|
| }
|
| }
|
| }
|
| }
|
| }
|
|
|
|
|
| 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": {
|
|
|
| if (config.query && useModernTable) {
|
| const resolution = resolveEmbeddingSource(settings);
|
| if (resolution.source !== null) {
|
|
|
|
|
|
|
|
|
| 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)
|
| ),
|
| });
|
|
|
| }
|
| }
|
|
|
| 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,
|
| };
|
| });
|
|
|
|
|
| if (settings.rerankEnabled && settings.rerankProviderModel && config.query) {
|
| rankedHybridItems = (await applyRerank(
|
| rankedHybridItems,
|
| config.query,
|
| settings.rerankProviderModel
|
| )) as typeof rankedHybridItems;
|
| }
|
|
|
|
|
| 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)),
|
| });
|
|
|
| }
|
| }
|
| }
|
| }
|
| }
|
|
|
|
|
| let ftsRows: MemoryRow[] = [];
|
| if (config.query && ftsAvailable) {
|
| ftsRows = buildFtsRows(apiKeyId, ftsColConfig);
|
| }
|
|
|
| query += ` ORDER BY ${columns.createdAt} DESC LIMIT 100`;
|
| const keywordRows = db.prepare(query).all(...params) as MemoryRow[];
|
|
|
|
|
| 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();
|
| });
|
|
|
|
|
| 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;
|
| }
|
|
|
|
|
|
|
| |
| |
| |
| |
|
|
| 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";
|
|
|
|
|
| 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) {
|
|
|
|
|
|
|
| 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,
|
| };
|
| }
|
|
|
|
|
| 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 {
|
|
|
| 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 {
|
|
|
| fallbackReason =
|
| "message" in embeddingResult ? (embeddingResult.message as string) : "embedding falhou";
|
| }
|
| } else if (!query) {
|
| fallbackReason = "query vazia — usando FTS5";
|
| } else {
|
| fallbackReason = resolution.reason;
|
| }
|
| }
|
|
|
|
|
| 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 {
|
|
|
| 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,
|
| };
|
| }
|
|
|
|
|
|
|
| |
| |
| |
|
|
| export async function engineStatus(): Promise<MemoryEngineStatus> {
|
| const settings = await getMemorySettings();
|
| const resolution = resolveEmbeddingSource(settings);
|
| const cacheStats = embeddingCacheStats();
|
|
|
|
|
| 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";
|
| }
|
|
|
|
|
| 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;
|
|
|
|
|
|
|
|
|
|
|
| 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));
|
| }
|
|
|
|
|
| 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,
|
| },
|
| };
|
| }
|
|
|