"use strict";
/**
 * Text Embedder
 *
 * Generates vector embeddings through remote embedding APIs. Vector storage
 * and similarity search remain local.
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.TextEmbedder = exports.MODEL_CAPABILITIES = exports.DEFAULT_SILICONFLOW_MODEL = exports.DEFAULT_JINA_MODEL = exports.DEFAULT_GEMINI_MODEL = exports.DEFAULT_MODEL = exports.DEFAULT_PROVIDER = void 0;
exports.getModelCapabilities = getModelCapabilities;
exports.createEmbedder = createEmbedder;
exports.DEFAULT_PROVIDER = 'gemini';
exports.DEFAULT_MODEL = 'gemini-embedding-2';
exports.DEFAULT_GEMINI_MODEL = 'gemini-embedding-2';
exports.DEFAULT_JINA_MODEL = 'jina-embeddings-v5-text-nano';
exports.DEFAULT_SILICONFLOW_MODEL = 'BAAI/bge-m3';
// Per-model capabilities — single source of truth for vector dimension,
// provider-appropriate batch sizing, and the per-text length ceiling. Adding
// a model means adding a row here plus a per-provider request builder.
exports.MODEL_CAPABILITIES = {
    'gemini-embedding-2': { dimension: 768, defaultBatchSize: 32, maxBatchSize: 100, maxInputChars: 28000 },
    'jina-embeddings-v5-text-nano': { dimension: 768, defaultBatchSize: 32, maxBatchSize: 2048, maxInputChars: 28000 },
    'BAAI/bge-m3': { dimension: 1024, defaultBatchSize: 1024, maxBatchSize: 4096, maxInputChars: 28000 },
};
const FALLBACK_DIMENSION = 768;
const FALLBACK_BATCH_SIZE = 32;
const FALLBACK_MAX_INPUT_CHARS = 28000;
function getModelCapabilities(modelId) {
    return exports.MODEL_CAPABILITIES[modelId];
}
const GEMINI_EMBEDDING_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models';
const JINA_EMBEDDING_ENDPOINT = 'https://api.jina.ai/v1/embeddings';
const SILICONFLOW_EMBEDDING_ENDPOINT = 'https://api.siliconflow.cn/v1/embeddings';
const JINA_MAX_REQUESTS_PER_MINUTE = 500;
const RATE_LIMIT_WINDOW_MS = 60_000;
const MAX_NETWORK_RETRY_ATTEMPTS = 3;
const INITIAL_RETRY_DELAY_MS = 1_000;
const RETRY_DELAY_MULTIPLIER = 2;
const MAX_RETRY_DELAY_MS = 64_000;
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
/**
 * Text Embedder using a remote embedding provider.
 *
 * Gemini batch generation uses batchEmbedContents with one request per node
 * text. Do not combine multiple nodes into one content.parts array because
 * that produces a single aggregate embedding instead of node-level vectors.
 */
class TextEmbedder {
    provider;
    apiKey;
    modelId;
    initialized = false;
    requestTimestamps = [];
    rateLimitQueue = Promise.resolve();
    statusReporter;
    constructor(options = {}) {
        this.provider = options.provider || exports.DEFAULT_PROVIDER;
        this.apiKey = options.apiKey;
        this.modelId = options.modelId || this.getDefaultModelForProvider(this.provider);
    }
    /**
     * Initialize the embedder by validating configuration.
     */
    async initialize() {
        if (this.initialized) {
            return;
        }
        if (!this.apiKey) {
            throw new Error(`${this.getProviderDisplayName()} API key is required for semantic search`);
        }
        this.initialized = true;
    }
    /**
     * Check if the embedder is initialized
     */
    isInitialized() {
        return this.initialized;
    }
    /**
     * Get the model ID being used
     */
    getModelId() {
        return this.modelId;
    }
    /**
     * Get the embedding dimension for the configured model.
     */
    getDimension() {
        return getModelCapabilities(this.modelId)?.dimension ?? FALLBACK_DIMENSION;
    }
    /**
     * Get the default batch size for the configured model.
     */
    getDefaultBatchSize() {
        return getModelCapabilities(this.modelId)?.defaultBatchSize ?? FALLBACK_BATCH_SIZE;
    }
    /**
     * Get the maximum allowed batch size for the configured model.
     */
    getMaxBatchSize() {
        return getModelCapabilities(this.modelId)?.maxBatchSize ?? FALLBACK_BATCH_SIZE;
    }
    /**
     * Per-text character ceiling enforced before sending. See ModelCapabilities.
     */
    getMaxInputChars() {
        return getModelCapabilities(this.modelId)?.maxInputChars ?? FALLBACK_MAX_INPUT_CHARS;
    }
    setStatusReporter(reporter) {
        this.statusReporter = reporter;
    }
    /**
     * Generate embedding for a single text
     */
    async embed(text) {
        const result = await this.embedBatch([text], 'document');
        const embedding = result.embeddings[0];
        if (!embedding) {
            throw new Error(`${this.getProviderDisplayName()} embedding response did not include values`);
        }
        return {
            embedding,
            dimension: embedding.length,
            model: result.model,
        };
    }
    /**
     * Generate embedding for a query.
     */
    async embedQuery(query) {
        const result = await this.embedBatch([query], 'search_query');
        const embedding = result.embeddings[0];
        if (!embedding) {
            throw new Error(`${this.getProviderDisplayName()} embedding response did not include values`);
        }
        return {
            embedding,
            dimension: embedding.length,
            model: result.model,
        };
    }
    /**
     * Generate embeddings for multiple texts in node order.
     */
    async embedBatch(texts, type = 'document') {
        if (!this.initialized) {
            throw new Error('Embedder not initialized. Call initialize() first.');
        }
        if (texts.length === 0) {
            return {
                embeddings: [],
                dimension: this.getDimension(),
                model: this.modelId,
                durationMs: 0,
            };
        }
        // Enforce the per-text input ceiling client-side. Gemini silently
        // truncates; Jina honors `truncate: true`; SiliconFlow rejects oversized
        // inputs with 400 even when its documented `truncate` field is set
        // (probed: not actually implemented for bge-m3). Truncating here gives
        // all three providers a single consistent ceiling and removes the need
        // for provider-specific server-side flags. `createNodeText` puts the
        // body last so truncation discards code-tail, not signature/docstring.
        const max = this.getMaxInputChars();
        const truncated = texts.map((t) => (t.length > max ? t.slice(0, max) : t));
        const startTime = Date.now();
        const embeddings = await this.embedPreparedBatch(truncated, type);
        return {
            embeddings,
            dimension: embeddings[0]?.length ?? this.getDimension(),
            model: this.modelId,
            durationMs: Date.now() - startTime,
        };
    }
    async embedPreparedBatch(texts, type) {
        if (!this.initialized) {
            throw new Error('Embedder not initialized. Call initialize() first.');
        }
        if (this.provider === 'jina') {
            return this.embedPreparedBatchWithJina(texts, type);
        }
        if (this.provider === 'siliconflow') {
            return this.embedPreparedBatchWithSiliconFlow(texts);
        }
        return this.embedPreparedBatchWithGemini(texts, type);
    }
    async embedPreparedBatchWithGemini(texts, type) {
        const url = `${GEMINI_EMBEDDING_ENDPOINT}/${encodeURIComponent(this.modelId)}:batchEmbedContents`;
        const model = `models/${this.modelId}`;
        const taskType = type === 'search_query' ? 'CODE_RETRIEVAL_QUERY' : 'RETRIEVAL_DOCUMENT';
        const response = await this.fetchWithRetry(url, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'x-goog-api-key': this.apiKey,
            },
            body: JSON.stringify({
                requests: texts.map((text) => ({
                    model,
                    content: {
                        parts: [{ text }],
                    },
                    taskType,
                    outputDimensionality: this.getDimension(),
                })),
            }),
        });
        if (!response.ok) {
            throw new Error(await this.formatApiErrorMessage('Gemini', response));
        }
        const json = await response.json();
        const values = json.embeddings?.map((embedding) => embedding.values);
        if (!values || values.length !== texts.length) {
            throw new Error('Gemini batch embedding response count did not match request count');
        }
        const embeddings = values.map((embeddingValues) => {
            if (!embeddingValues || embeddingValues.length === 0) {
                throw new Error('Gemini embedding response did not include values');
            }
            return new Float32Array(embeddingValues);
        });
        if (embeddings.some((embedding) => embedding.length !== embeddings[0].length)) {
            throw new Error('Gemini batch embedding response contained mixed dimensions');
        }
        return embeddings;
    }
    async embedPreparedBatchWithJina(texts, type) {
        await this.throttleRequests(1);
        const response = await this.fetchWithRetry(JINA_EMBEDDING_ENDPOINT, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                Authorization: `Bearer ${this.apiKey}`,
            },
            body: JSON.stringify({
                model: this.modelId,
                input: texts.map((text) => this.prepareJinaText(text, type)),
                normalized: true,
                embedding_type: 'float',
                truncate: true,
            }),
        });
        if (!response.ok) {
            throw new Error(await this.formatApiErrorMessage('Jina', response));
        }
        return this.parseOpenAICompatibleResponse(response, 'Jina', texts.length);
    }
    async embedPreparedBatchWithSiliconFlow(texts) {
        const response = await this.fetchWithRetry(SILICONFLOW_EMBEDDING_ENDPOINT, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                Authorization: `Bearer ${this.apiKey}`,
            },
            body: JSON.stringify({
                model: this.modelId,
                input: texts,
                encoding_format: 'float',
            }),
        });
        if (!response.ok) {
            throw new Error(await this.formatApiErrorMessage('SiliconFlow', response));
        }
        return this.parseOpenAICompatibleResponse(response, 'SiliconFlow', texts.length);
    }
    async parseOpenAICompatibleResponse(response, providerLabel, expectedCount) {
        const json = (await response.json());
        const values = json.data?.map((embedding) => embedding.embedding);
        if (!values || values.length !== expectedCount) {
            throw new Error(`${providerLabel} batch embedding response count did not match request count`);
        }
        const embeddings = values.map((embeddingValues) => {
            if (!embeddingValues || embeddingValues.length === 0) {
                throw new Error(`${providerLabel} embedding response did not include values`);
            }
            return new Float32Array(embeddingValues);
        });
        if (embeddings.some((embedding) => embedding.length !== embeddings[0].length)) {
            throw new Error(`${providerLabel} batch embedding response contained mixed dimensions`);
        }
        return embeddings;
    }
    async fetchWithRetry(url, init) {
        let lastNetworkError;
        for (let attempt = 0;; attempt++) {
            try {
                const response = await fetch(url, init);
                if (!this.isRetryableStatus(response.status)) {
                    return response;
                }
                const retryDelayMs = await this.getRetryDelayMs(attempt, response);
                this.reportRetryWait(retryDelayMs, attempt + 1);
                await this.sleep(retryDelayMs);
            }
            catch (error) {
                lastNetworkError = error;
                if (attempt >= MAX_NETWORK_RETRY_ATTEMPTS) {
                    break;
                }
                const retryDelayMs = await this.getRetryDelayMs(attempt);
                this.reportRetryWait(retryDelayMs, attempt + 1);
                await this.sleep(retryDelayMs);
            }
        }
        throw lastNetworkError instanceof Error
            ? lastNetworkError
            : new Error(`${this.getProviderDisplayName()} embedding request failed: ${String(lastNetworkError)}`);
    }
    isRetryableStatus(status) {
        return RETRYABLE_STATUS_CODES.has(status);
    }
    async getRetryDelayMs(attempt, response) {
        const retryInfoDelayMs = await this.getRetryInfoDelayMs(response);
        const exponentialDelay = Math.min(INITIAL_RETRY_DELAY_MS * Math.pow(RETRY_DELAY_MULTIPLIER, attempt), MAX_RETRY_DELAY_MS);
        return Math.max(retryInfoDelayMs ?? 0, exponentialDelay);
    }
    async getRetryInfoDelayMs(response) {
        const clonedResponse = response?.clone?.();
        const body = await clonedResponse?.text().catch(() => '');
        if (!body) {
            return undefined;
        }
        try {
            const parsed = JSON.parse(body);
            const retryDelay = parsed.error?.details?.find((detail) => detail?.['@type'] === 'type.googleapis.com/google.rpc.RetryInfo')?.retryDelay;
            return this.parseDurationMs(retryDelay);
        }
        catch {
            return undefined;
        }
    }
    parseDurationMs(duration) {
        if (!duration) {
            return undefined;
        }
        const match = duration.trim().match(/^(-?\d+(?:\.\d+)?)s$/);
        if (!match) {
            return undefined;
        }
        const seconds = Number(match[1]);
        if (!Number.isFinite(seconds) || seconds < 0) {
            return undefined;
        }
        return Math.ceil(seconds * 1000);
    }
    async sleep(ms) {
        await new Promise((resolve) => setTimeout(resolve, ms));
    }
    reportRetryWait(retryInMs, attempt) {
        this.statusReporter?.({
            phase: 'retry_wait',
            provider: this.getProviderDisplayName(),
            retryInMs,
            attempt,
        });
    }
    async throttleRequests(cost) {
        const reservation = this.rateLimitQueue.then(() => this.reserveRequestSlots(cost));
        this.rateLimitQueue = reservation.catch(() => undefined);
        await reservation;
    }
    async reserveRequestSlots(cost) {
        if (cost > JINA_MAX_REQUESTS_PER_MINUTE) {
            throw new Error(`${this.getProviderDisplayName()} embedding batch size ${cost} exceeds the ${JINA_MAX_REQUESTS_PER_MINUTE} requests-per-minute limit`);
        }
        while (true) {
            const now = Date.now();
            this.requestTimestamps = this.requestTimestamps.filter(({ timestamp }) => now - timestamp < RATE_LIMIT_WINDOW_MS);
            const usedSlots = this.requestTimestamps.reduce((sum, entry) => sum + entry.cost, 0);
            if (usedSlots + cost <= JINA_MAX_REQUESTS_PER_MINUTE) {
                this.requestTimestamps.push({ timestamp: now, cost });
                return;
            }
            const oldestRequestAt = this.requestTimestamps[0].timestamp;
            const waitMs = Math.max(1, RATE_LIMIT_WINDOW_MS - (now - oldestRequestAt));
            await new Promise((resolve) => setTimeout(resolve, waitMs));
        }
    }
    prepareJinaText(text, type) {
        return `${type === 'search_query' ? 'Query' : 'Document'}: ${text}`;
    }
    async formatApiErrorMessage(providerName, response) {
        const body = await response.text().catch(() => '');
        const normalizedBody = this.normalizeErrorBody(body);
        const status = response.statusText
            ? `${response.status} ${response.statusText}`
            : String(response.status);
        return `${providerName} embedding request failed (${status}): ${normalizedBody || 'No response body'}`;
    }
    normalizeErrorBody(body) {
        const trimmed = body.trim();
        if (!trimmed) {
            return '';
        }
        try {
            return JSON.stringify(JSON.parse(trimmed));
        }
        catch {
            return trimmed;
        }
    }
    getDefaultModelForProvider(provider) {
        switch (provider) {
            case 'jina':
                return exports.DEFAULT_JINA_MODEL;
            case 'siliconflow':
                return exports.DEFAULT_SILICONFLOW_MODEL;
            default:
                return exports.DEFAULT_GEMINI_MODEL;
        }
    }
    getProviderDisplayName() {
        switch (this.provider) {
            case 'jina':
                return 'Jina';
            case 'siliconflow':
                return 'SiliconFlow';
            default:
                return 'Gemini';
        }
    }
    /**
     * Create text representation of a code node for embedding.
     */
    static createNodeText(node) {
        const parts = [];
        parts.push(`${node.kind}: ${node.name}`);
        if (node.qualifiedName && node.qualifiedName !== node.name) {
            parts.push(`path: ${node.qualifiedName}`);
        }
        parts.push(`file: ${node.filePath}`);
        if (node.signature) {
            parts.push(`signature: ${node.signature}`);
        }
        if (node.docstring) {
            parts.push(`documentation: ${node.docstring}`);
        }
        // Body last: gemini-embedding-2 silently truncates beyond 8192 tokens, so
        // putting body at the tail means an overflow loses code-tail, not
        // signature or docstring — the bits that carry the most retrieval signal
        // per token.
        if (node.body) {
            parts.push(`code:\n${node.body}`);
        }
        return parts.join('\n');
    }
    /**
     * Compute cosine similarity between two embeddings
     */
    static cosineSimilarity(a, b) {
        if (a.length !== b.length) {
            throw new Error('Embeddings must have the same dimension');
        }
        let dotProduct = 0;
        let normA = 0;
        let normB = 0;
        for (let i = 0; i < a.length; i++) {
            const aVal = a[i];
            const bVal = b[i];
            dotProduct += aVal * bVal;
            normA += aVal * aVal;
            normB += bVal * bVal;
        }
        normA = Math.sqrt(normA);
        normB = Math.sqrt(normB);
        if (normA === 0 || normB === 0) {
            return 0;
        }
        return dotProduct / (normA * normB);
    }
    /**
     * Release resources
     */
    dispose() {
        this.initialized = false;
    }
}
exports.TextEmbedder = TextEmbedder;
/**
 * Create a text embedder instance
 */
function createEmbedder(options) {
    return new TextEmbedder(options);
}
//# sourceMappingURL=embedder.js.map