diff --git a/AGENTS.md b/AGENTS.md index cd70281f9..f9bd21836 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (2169 symbols, 5213 relationships, 165 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **gitnexus-fork** (2179 symbols, 5243 relationships, 166 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -17,7 +17,7 @@ This project is indexed by GitNexus as **GitNexus** (2169 symbols, 5213 relation 1. `gitnexus_query({query: ""})` — find execution flows related to the issue 2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step +3. `READ gitnexus://repo/gitnexus-fork/process/{processName}` — trace the full execution flow step by step 4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed ## When Refactoring @@ -56,10 +56,10 @@ This project is indexed by GitNexus as **GitNexus** (2169 symbols, 5213 relation | Resource | Use for | |----------|---------| -| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness | -| `gitnexus://repo/GitNexus/clusters` | All functional areas | -| `gitnexus://repo/GitNexus/processes` | All execution flows | -| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace | +| `gitnexus://repo/gitnexus-fork/context` | Codebase overview, check index freshness | +| `gitnexus://repo/gitnexus-fork/clusters` | All functional areas | +| `gitnexus://repo/gitnexus-fork/processes` | All execution flows | +| `gitnexus://repo/gitnexus-fork/process/{name}` | Step-by-step execution trace | ## Self-Check Before Finishing diff --git a/CLAUDE.md b/CLAUDE.md index cd70281f9..f9bd21836 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (2169 symbols, 5213 relationships, 165 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **gitnexus-fork** (2179 symbols, 5243 relationships, 166 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -17,7 +17,7 @@ This project is indexed by GitNexus as **GitNexus** (2169 symbols, 5213 relation 1. `gitnexus_query({query: ""})` — find execution flows related to the issue 2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step +3. `READ gitnexus://repo/gitnexus-fork/process/{processName}` — trace the full execution flow step by step 4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed ## When Refactoring @@ -56,10 +56,10 @@ This project is indexed by GitNexus as **GitNexus** (2169 symbols, 5213 relation | Resource | Use for | |----------|---------| -| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness | -| `gitnexus://repo/GitNexus/clusters` | All functional areas | -| `gitnexus://repo/GitNexus/processes` | All execution flows | -| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace | +| `gitnexus://repo/gitnexus-fork/context` | Codebase overview, check index freshness | +| `gitnexus://repo/gitnexus-fork/clusters` | All functional areas | +| `gitnexus://repo/gitnexus-fork/processes` | All execution flows | +| `gitnexus://repo/gitnexus-fork/process/{name}` | Step-by-step execution trace | ## Self-Check Before Finishing diff --git a/gitnexus/src/core/embeddings/embedder.ts b/gitnexus/src/core/embeddings/embedder.ts index 072aaa6f7..96cd2a57d 100644 --- a/gitnexus/src/core/embeddings/embedder.ts +++ b/gitnexus/src/core/embeddings/embedder.ts @@ -18,92 +18,8 @@ import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/tran import { existsSync } from 'fs'; import { execFileSync } from 'child_process'; import { join } from 'path'; -import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type HttpEmbeddingConfig, type ModelProgress } from './types.js'; - -// ─── HTTP Embedding Backend ─────────────────────────────────────────────────── -// When GITNEXUS_EMBEDDING_URL + GITNEXUS_EMBEDDING_MODEL are set, embedding -// calls proxy to a remote OpenAI-compatible /v1/embeddings endpoint. - -function getHttpConfig(): HttpEmbeddingConfig | null { - const baseUrl = process.env.GITNEXUS_EMBEDDING_URL; - const model = process.env.GITNEXUS_EMBEDDING_MODEL; - if (!baseUrl || !model) return null; - return { - baseUrl: baseUrl.replace(/\/+$/, ''), - model, - apiKey: process.env.GITNEXUS_EMBEDDING_API_KEY ?? 'unused', - dimensions: process.env.GITNEXUS_EMBEDDING_DIMS - ? parseInt(process.env.GITNEXUS_EMBEDDING_DIMS, 10) - : undefined, - }; -} - -let httpConfig: HttpEmbeddingConfig | null | undefined; -let httpDimensions: number | null = null; - -const HTTP_TIMEOUT_MS = 30_000; -const HTTP_MAX_RETRIES = 2; -const HTTP_RETRY_BACKOFF_MS = 1_000; - -async function httpEmbedBatch( - url: string, - batch: string[], - model: string, - apiKey: string, - attempt = 0, -): Promise> { - const resp = await fetch(url, { - method: 'POST', - signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}`, - }, - body: JSON.stringify({ input: batch, model }), - }); - - if (!resp.ok) { - const status = resp.status; - if ((status === 429 || status >= 500) && attempt < HTTP_MAX_RETRIES) { - const delay = HTTP_RETRY_BACKOFF_MS * (attempt + 1); - await new Promise(r => setTimeout(r, delay)); - return httpEmbedBatch(url, batch, model, apiKey, attempt + 1); - } - throw new Error(`Embedding endpoint returned ${status}`); - } - - const data = (await resp.json()) as { data: Array<{ embedding: number[] }> }; - return data.data; -} - -async function httpEmbed(texts: string[]): Promise { - if (httpConfig === undefined) httpConfig = getHttpConfig(); - if (!httpConfig) throw new Error('HTTP embedding not configured'); - - const url = `${httpConfig.baseUrl}/embeddings`; - const batchSize = 64; - const allVectors: Float32Array[] = []; - - for (let i = 0; i < texts.length; i += batchSize) { - const batch = texts.slice(i, i + batchSize); - const items = await httpEmbedBatch(url, batch, httpConfig.model, httpConfig.apiKey); - - for (const item of items) { - allVectors.push(new Float32Array(item.embedding)); - } - - if (httpDimensions === null && items.length > 0) { - httpDimensions = items[0].embedding.length; - } - } - - return allVectors; -} - -function isHttpMode(): boolean { - if (httpConfig === undefined) httpConfig = getHttpConfig(); - return httpConfig !== null; -} +import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } from './types.js'; +import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js'; /** * Check whether CUDA libraries are actually available on this system. @@ -292,13 +208,11 @@ export const isEmbedderReady = (): boolean => { /** * Get the effective embedding dimensions. - * Returns configured dimensions. In HTTP mode, uses GITNEXUS_EMBEDDING_DIMS - * or falls back to auto-detected dims from the last HTTP response. + * In HTTP mode, uses GITNEXUS_EMBEDDING_DIMS if set, otherwise the default. */ export const getEmbeddingDimensions = (): number => { if (isHttpMode()) { - const cfg = getHttpConfig(); - return cfg?.dimensions ?? httpDimensions ?? DEFAULT_EMBEDDING_CONFIG.dimensions; + return getHttpDimensions() ?? DEFAULT_EMBEDDING_CONFIG.dimensions; } return DEFAULT_EMBEDDING_CONFIG.dimensions; }; diff --git a/gitnexus/src/core/embeddings/http-client.ts b/gitnexus/src/core/embeddings/http-client.ts new file mode 100644 index 000000000..331e45e55 --- /dev/null +++ b/gitnexus/src/core/embeddings/http-client.ts @@ -0,0 +1,177 @@ +/** + * HTTP Embedding Client + * + * Shared fetch+retry logic for OpenAI-compatible /v1/embeddings endpoints. + * Imported by both the core embedder (batch) and MCP embedder (query). + */ + +const HTTP_TIMEOUT_MS = 30_000; +const HTTP_MAX_RETRIES = 2; +const HTTP_RETRY_BACKOFF_MS = 1_000; +const HTTP_BATCH_SIZE = 64; + +interface HttpConfig { + baseUrl: string; + model: string; + apiKey: string; + dimensions?: number; +} + +/** + * Build config from the current process.env snapshot. + * Returns null when GITNEXUS_EMBEDDING_URL + GITNEXUS_EMBEDDING_MODEL are unset. + * Not cached — env vars are read fresh so late configuration takes effect. + */ +const readConfig = (): HttpConfig | null => { + const baseUrl = process.env.GITNEXUS_EMBEDDING_URL; + const model = process.env.GITNEXUS_EMBEDDING_MODEL; + if (!baseUrl || !model) return null; + + const rawDims = process.env.GITNEXUS_EMBEDDING_DIMS; + let dimensions: number | undefined; + if (rawDims !== undefined) { + const parsed = parseInt(rawDims, 10); + if (Number.isNaN(parsed) || parsed <= 0) { + throw new Error( + `GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`, + ); + } + dimensions = parsed; + } + + return { + baseUrl: baseUrl.replace(/\/+$/, ''), + model, + apiKey: process.env.GITNEXUS_EMBEDDING_API_KEY ?? 'unused', + dimensions, + }; +}; + +/** + * Check whether HTTP embedding mode is active (env vars are set). + */ +export const isHttpMode = (): boolean => readConfig() !== null; + +/** + * Return the configured embedding dimensions for HTTP mode, or undefined + * if HTTP mode is not active or no explicit dimensions are set. + */ +export const getHttpDimensions = (): number | undefined => readConfig()?.dimensions; + +/** + * Return a safe representation of a URL for error messages. + * Strips query string (may contain tokens) and userinfo. + */ +const safeUrl = (url: string): string => { + try { + const u = new URL(url); + return `${u.protocol}//${u.host}${u.pathname}`; + } catch { + return ''; + } +}; + +interface EmbeddingItem { + embedding: number[]; +} + +/** + * Send a single batch of texts to the embedding endpoint with retry. + * + * @param url - Full endpoint URL (e.g. https://host/v1/embeddings) + * @param batch - Texts to embed + * @param model - Model name for the request body + * @param apiKey - Bearer token (only used in Authorization header) + * @param batchIndex - Logical batch number (for error context) + * @param attempt - Current retry attempt (internal) + */ +const httpEmbedBatch = async ( + url: string, + batch: string[], + model: string, + apiKey: string, + batchIndex = 0, + attempt = 0, +): Promise => { + let resp: Response; + try { + resp = await fetch(url, { + method: 'POST', + signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ input: batch, model }), + }); + } catch (err) { + // DNS, timeout, connection errors — add context without leaking the key + if (attempt < HTTP_MAX_RETRIES) { + const delay = HTTP_RETRY_BACKOFF_MS * (attempt + 1); + await new Promise(r => setTimeout(r, delay)); + return httpEmbedBatch(url, batch, model, apiKey, batchIndex, attempt + 1); + } + const reason = err instanceof Error ? err.message : String(err); + throw new Error( + `Embedding request failed (${safeUrl(url)}, batch ${batchIndex}): ${reason}`, + ); + } + + if (!resp.ok) { + const status = resp.status; + if ((status === 429 || status >= 500) && attempt < HTTP_MAX_RETRIES) { + const delay = HTTP_RETRY_BACKOFF_MS * (attempt + 1); + await new Promise(r => setTimeout(r, delay)); + return httpEmbedBatch(url, batch, model, apiKey, batchIndex, attempt + 1); + } + throw new Error( + `Embedding endpoint returned ${status} (${safeUrl(url)}, batch ${batchIndex})`, + ); + } + + const data = (await resp.json()) as { data: EmbeddingItem[] }; + return data.data; +}; + +/** + * Embed texts via the HTTP backend, splitting into batches. + * Reads config from env vars on every call. + * + * @param texts - Array of texts to embed + * @returns Array of Float32Array embedding vectors + */ +export const httpEmbed = async (texts: string[]): Promise => { + const config = readConfig(); + if (!config) throw new Error('HTTP embedding not configured'); + + const url = `${config.baseUrl}/embeddings`; + const allVectors: Float32Array[] = []; + + for (let i = 0; i < texts.length; i += HTTP_BATCH_SIZE) { + const batch = texts.slice(i, i + HTTP_BATCH_SIZE); + const batchIndex = Math.floor(i / HTTP_BATCH_SIZE); + const items = await httpEmbedBatch(url, batch, config.model, config.apiKey, batchIndex); + + for (const item of items) { + allVectors.push(new Float32Array(item.embedding)); + } + } + + return allVectors; +}; + +/** + * Embed a single query text via the HTTP backend. + * Convenience for MCP search where only one vector is needed. + * + * @param text - Query text to embed + * @returns Embedding vector as number array + */ +export const httpEmbedQuery = async (text: string): Promise => { + const config = readConfig(); + if (!config) throw new Error('HTTP embedding not configured'); + + const url = `${config.baseUrl}/embeddings`; + const items = await httpEmbedBatch(url, [text], config.model, config.apiKey); + return items[0].embedding; +}; diff --git a/gitnexus/src/core/embeddings/index.ts b/gitnexus/src/core/embeddings/index.ts index 4b4f10bb5..19b326187 100644 --- a/gitnexus/src/core/embeddings/index.ts +++ b/gitnexus/src/core/embeddings/index.ts @@ -5,6 +5,7 @@ */ export * from './types.js'; +export * from './http-client.js'; export * from './embedder.js'; export * from './text-generator.js'; export * from './embedding-pipeline.js'; diff --git a/gitnexus/src/core/embeddings/types.ts b/gitnexus/src/core/embeddings/types.ts index 12362097d..25af985c8 100644 --- a/gitnexus/src/core/embeddings/types.ts +++ b/gitnexus/src/core/embeddings/types.ts @@ -65,20 +65,6 @@ export interface EmbeddingConfig { maxSnippetLength: number; } -/** - * Configuration for HTTP embedding endpoint (OpenAI-compatible /v1/embeddings). - * Populated from GITNEXUS_EMBEDDING_* environment variables. - */ -export interface HttpEmbeddingConfig { - /** Base URL for the embedding API (must include /v1) */ - baseUrl: string; - /** Model name to send in the request */ - model: string; - /** API key for authentication */ - apiKey: string; - /** Vector dimensions — must match model output (default: 384) */ - dimensions?: number; -} /** * Default embedding configuration diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index f995a730f..5a74ef78e 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -399,7 +399,13 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( // ============================================================================ /** Embedding vector dimensions. Default 384 (snowflake-arctic-embed-xs). */ -export const EMBEDDING_DIMS = parseInt(process.env.GITNEXUS_EMBEDDING_DIMS ?? '384', 10); +const _rawDims = parseInt(process.env.GITNEXUS_EMBEDDING_DIMS ?? '384', 10); +if (Number.isNaN(_rawDims) || _rawDims <= 0) { + throw new Error( + `GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${process.env.GITNEXUS_EMBEDDING_DIMS}"`, + ); +} +export const EMBEDDING_DIMS = _rawDims; export const EMBEDDING_SCHEMA = ` CREATE NODE TABLE ${EMBEDDING_TABLE_NAME} ( diff --git a/gitnexus/src/mcp/core/embedder.ts b/gitnexus/src/mcp/core/embedder.ts index ab6f64752..11261ff36 100644 --- a/gitnexus/src/mcp/core/embedder.ts +++ b/gitnexus/src/mcp/core/embedder.ts @@ -6,16 +6,10 @@ */ import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers'; - -// HTTP embedding config -const HTTP_URL = process.env.GITNEXUS_EMBEDDING_URL ?? ''; -const HTTP_MODEL = process.env.GITNEXUS_EMBEDDING_MODEL ?? ''; -const HTTP_KEY = process.env.GITNEXUS_EMBEDDING_API_KEY ?? 'unused'; -const USE_HTTP = !!(HTTP_URL && HTTP_MODEL); +import { isHttpMode, getHttpDimensions, httpEmbedQuery } from '../../core/embeddings/http-client.js'; // Model config const MODEL_ID = 'Snowflake/snowflake-arctic-embed-xs'; -const EMBEDDING_DIMS = parseInt(process.env.GITNEXUS_EMBEDDING_DIMS ?? '384', 10); // Module-level state for singleton pattern let embedderInstance: FeatureExtractionPipeline | null = null; @@ -26,7 +20,7 @@ let initPromise: Promise | null = null; * Initialize the embedding model (lazy, on first search) */ export const initEmbedder = async (): Promise => { - if (USE_HTTP) { + if (isHttpMode()) { throw new Error('initEmbedder() should not be called in HTTP mode.'); } @@ -97,38 +91,14 @@ export const initEmbedder = async (): Promise => { /** * Check if embedder is ready */ -export const isEmbedderReady = (): boolean => USE_HTTP || embedderInstance !== null; +export const isEmbedderReady = (): boolean => isHttpMode() || embedderInstance !== null; /** * Embed a query text for semantic search */ export const embedQuery = async (query: string): Promise => { - if (USE_HTTP) { - const url = `${HTTP_URL.replace(/\/+$/, '')}/embeddings`; - const body = JSON.stringify({ input: [query], model: HTTP_MODEL }); - const headers = { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${HTTP_KEY}`, - }; - - for (let attempt = 0; attempt <= 1; attempt++) { - const resp = await fetch(url, { - method: 'POST', - signal: AbortSignal.timeout(30_000), - headers, - body, - }); - if (!resp.ok) { - if ((resp.status === 429 || resp.status >= 500) && attempt < 1) { - await new Promise(r => setTimeout(r, 1_000)); - continue; - } - throw new Error(`Embedding endpoint returned ${resp.status}`); - } - const data = (await resp.json()) as { data: Array<{ embedding: number[] }> }; - return data.data[0].embedding; - } - throw new Error('Embedding request failed after retry'); + if (isHttpMode()) { + return httpEmbedQuery(query); } const embedder = await initEmbedder(); @@ -144,7 +114,9 @@ export const embedQuery = async (query: string): Promise => { /** * Get embedding dimensions */ -export const getEmbeddingDims = (): number => EMBEDDING_DIMS; +export const getEmbeddingDims = (): number => { + return getHttpDimensions() ?? 384; +}; /** * Cleanup embedder