"use strict";
/**
* @file retrieval/embedding.ts
* Embedding-based semantic retrieval via local LM Studio API.
*
* Uses the loaded embedding model (e.g., nomic-embed-text) to compute
* dense vector similarity. Falls back gracefully if no embedding model
* is available. Retries availability check every 60 seconds after failure.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.setEmbeddingEndpoint = setEmbeddingEndpoint;
exports.isEmbeddingAvailable = isEmbeddingAvailable;
exports.removeEmbedding = removeEmbedding;
exports.clearEmbeddingCache = clearEmbeddingCache;
exports.semanticSearch = semanticSearch;
const DEFAULT_EMBEDDING_ENDPOINT = "http://localhost:1234/v1/embeddings";
const EMBEDDING_TIMEOUT_MS = 5_000;
const RETRY_COOLDOWN_MS = 60_000; // retry every 60s after failure
/** Custom endpoint set from config — takes priority over default. */
let customEndpoint = null;
/**
* Set custom embedding endpoint from plugin config.
* Empty strings are ignored (default will be used).
*/
function setEmbeddingEndpoint(endpoint) {
if (endpoint && endpoint.trim()) {
customEndpoint = endpoint.trim();
// Reset availability check when endpoint changes
embeddingAvailable = null;
}
}
function getEndpoint() {
return customEndpoint || DEFAULT_EMBEDDING_ENDPOINT;
}
/** Cosine similarity between two vectors. */
function cosineSimilarity(a, b) {
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 0 : dot / denom;
}
/** Cache of document embeddings keyed by memory ID. Max size to prevent unbounded growth. */
const MAX_CACHE_SIZE = 2000;
const embeddingCache = new Map();
/** Whether embedding is available. Null = not checked yet. */
let embeddingAvailable = null;
/** Timestamp of last failed check — used for retry cooldown. */
let lastFailedAt = 0;
/**
* Call the local embedding API. Returns null if unavailable.
*/
async function getEmbeddings(texts) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), EMBEDDING_TIMEOUT_MS);
const response = await fetch(getEndpoint(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: texts }),
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
embeddingAvailable = false;
lastFailedAt = Date.now();
return null;
}
const data = await response.json();
embeddingAvailable = true;
return data.data.map((d) => d.embedding);
}
catch {
embeddingAvailable = false;
lastFailedAt = Date.now();
return null;
}
}
/**
* Check if embedding API is reachable.
* Retries after RETRY_COOLDOWN_MS if previously failed.
*/
async function isEmbeddingAvailable() {
if (embeddingAvailable === true)
return true;
if (embeddingAvailable === false) {
// Retry after cooldown period
if (Date.now() - lastFailedAt < RETRY_COOLDOWN_MS)
return false;
embeddingAvailable = null; // allow retry
}
const result = await getEmbeddings(["test"]);
return result !== null;
}
/**
* Remove a memory from the embedding cache.
*/
function removeEmbedding(id) {
embeddingCache.delete(id);
}
/**
* Clear the entire embedding cache (e.g., on index rebuild).
*/
function clearEmbeddingCache() {
embeddingCache.clear();
}
/**
* Evict oldest entries from cache when it exceeds MAX_CACHE_SIZE.
*/
function evictCacheIfNeeded() {
if (embeddingCache.size <= MAX_CACHE_SIZE)
return;
const toRemove = embeddingCache.size - MAX_CACHE_SIZE;
const iter = embeddingCache.keys();
for (let i = 0; i < toRemove; i++) {
const key = iter.next().value;
if (key)
embeddingCache.delete(key);
}
}
/**
* Semantic search: compute query embedding and rank candidate memories.
*
* @param query User query string
* @param candidates Array of { id, content } to rank
* @param limit Max results to return
* @returns Sorted array of [id, similarity] or null if embedding unavailable
*/
async function semanticSearch(query, candidates, limit) {
if (candidates.length === 0)
return [];
// Gather texts to embed: query + any uncached candidates
const uncachedCandidates = candidates.filter(c => !embeddingCache.has(c.id));
const textsToEmbed = [query, ...uncachedCandidates.map(c => c.content)];
const embeddings = await getEmbeddings(textsToEmbed);
if (!embeddings)
return null;
// Store query embedding
const queryEmbedding = embeddings[0];
// Cache new candidate embeddings
for (let i = 0; i < uncachedCandidates.length; i++) {
embeddingCache.set(uncachedCandidates[i].id, embeddings[i + 1]);
}
evictCacheIfNeeded();
// Score all candidates
const scored = [];
for (const candidate of candidates) {
const candidateEmbedding = embeddingCache.get(candidate.id);
if (!candidateEmbedding)
continue;
const sim = cosineSimilarity(queryEmbedding, candidateEmbedding);
scored.push([candidate.id, sim]);
}
scored.sort((a, b) => b[1] - a[1]);
return scored.slice(0, limit);
}
//# sourceMappingURL=embedding.js.map