{"version":3,"file":"embedding.d.ts","sourceRoot":"","sources":["../../../src/core/workspace/embedding.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAEtF,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,SAAS,GAAG,OAAO,GAAG,QAAQ,CAAC;AAExE,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,aAAa,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,eAAO,MAAM,kBAAkB,KAAK,CAAC;AACrC,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC,0EAA0E;AAC1E,qBAAa,uBAAwB,YAAW,gBAAgB;IAC/D,SAAS,SAAa;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,KAAK,UAAQ;IAEb,YAAY,OAAO,SAAkB,EAAE,UAAU,SAAqB,EAIrE;IAEK,KAAK,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAG/D;IAED,iFAAiF;IACjF,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAmBhC;CACD;AAED,+EAA+E;AAC/E,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C;AAED,0DAA0D;AAC1D,qBAAa,qBAAsB,YAAW,gBAAgB;IAC7D,SAAS,SAAW;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,KAAK,UAAQ;IACb,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,MAAM,CAAC,CAAS;IAExB,YAAY,MAAM,EAAE;QACnB,QAAQ,EAAE,MAAM,CAAC;QACjB,OAAO,EAAE,MAAM,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,kBAAkB,CAAC,EAAE,MAAM,CAAC;QAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;KAChB,EAMA;IAED,cAAc,IAAI,IAAI,CAUrB;IAEK,KAAK,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAuB/D;CACD;AAED,wFAAsF;AACtF,qBAAa,wBAAyB,YAAW,gBAAgB;IAChE,SAAS,SAAc;IACvB,OAAO,SAAc;IACrB,UAAU,SAAK;IACf,kBAAkB,SAAK;IACvB,KAAK,UAAQ;IACP,KAAK,IAAI,OAAO,CAAC,eAAe,CAAC,CAEtC;CACD;AAED,6CAA6C;AAC7C,wBAAgB,uBAAuB,CAAC,MAAM,CAAC,EAAE,eAAe,GAAG,gBAAgB,CA4BlF;AAED,iFAAiF;AACjF,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAGnD;AAED,8DAA8D;AAC9D,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAI5E","sourcesContent":["/**\n * Pluggable embedding backends.\n *\n * Privacy-by-default: workspace source is only ever sent to explicitly\n * configured LOCAL (loopback) backends. Remote OpenAI-compatible embedding is\n * disabled unless explicitly enabled by policy. A deterministic fixture backend\n * provides the no-paid-dependency test path.\n */\n\nimport { createHash, randomBytes } from \"node:crypto\";\nimport type { EmbeddingBackend, EmbeddingRequest, EmbeddingResult } from \"./types.js\";\n\nexport type EmbeddingMode = \"disabled\" | \"fixture\" | \"local\" | \"remote\";\n\nexport interface EmbeddingConfig {\n\tmode: EmbeddingMode;\n\tmodelId?: string;\n\tdimensions?: number;\n\tmaximumInputTokens?: number;\n\tendpoint?: string; // loopback OpenAI-compatible endpoint (local) or remote (opt-in)\n\tapiKey?: string;\n\tpurpose?: string; // e.g. \"index\", \"query\"\n\tallowed?: boolean; // policy authorization for remote\n}\n\nexport const DEFAULT_DIMENSIONS = 32;\nexport const DEFAULT_MAX_TOKENS = 2048;\n\n/** Deterministic fixture embedder: stable hashed bag-of-ngrams vector. */\nexport class FixtureEmbeddingBackend implements EmbeddingBackend {\n\tbackendId = \"fixture\";\n\tmodelId: string;\n\tdimensions: number;\n\tmaximumInputTokens: number;\n\tlocal = true;\n\n\tconstructor(modelId = \"fixture-local\", dimensions = DEFAULT_DIMENSIONS) {\n\t\tthis.modelId = modelId;\n\t\tthis.dimensions = dimensions;\n\t\tthis.maximumInputTokens = DEFAULT_MAX_TOKENS;\n\t}\n\n\tasync embed(request: EmbeddingRequest): Promise<EmbeddingResult> {\n\t\tconst embeddings = request.texts.map((t) => this.vectorFor(t));\n\t\treturn { embeddings, modelId: this.modelId, dimensions: this.dimensions };\n\t}\n\n\t/** Deterministic unit-norm vector derived from token hashes (ngram coverage). */\n\tvectorFor(text: string): number[] {\n\t\tconst vec = new Array<number>(this.dimensions).fill(0);\n\t\tconst tokens = text.toLowerCase().match(/[a-z0-9_]{2,}/g) ?? [];\n\t\tfor (const token of tokens) {\n\t\t\tconst h = createHash(\"sha256\").update(token).digest();\n\t\t\tconst idx = h[0] % this.dimensions;\n\t\t\tconst sign = (h[1] & 1) === 0 ? 1 : -1;\n\t\t\tvec[idx] += sign;\n\t\t}\n\t\t// n-gram coverage for phrase signals\n\t\tfor (let i = 0; i < tokens.length - 1; i++) {\n\t\t\tconst bigram = `${tokens[i]}_${tokens[i + 1]}`;\n\t\t\tconst h = createHash(\"sha256\").update(bigram).digest();\n\t\t\tconst idx = h[0] % this.dimensions;\n\t\t\tvec[idx] += 2;\n\t\t}\n\t\t// normalize to unit length\n\t\tconst norm = Math.sqrt(vec.reduce((a, b) => a + b * b, 0)) || 1;\n\t\treturn vec.map((v) => +(v / norm).toFixed(6));\n\t}\n}\n\n/** Drag a seed-based deterministic vector (for test stability across runs). */\nexport function deterministicSeed(): string {\n\treturn createHash(\"sha256\").update(randomBytes(8)).digest(\"hex\").slice(0, 8);\n}\n\n/** Local loopback OpenAI-compatible embedding backend. */\nexport class LocalEmbeddingBackend implements EmbeddingBackend {\n\tbackendId = \"local\";\n\tmodelId: string;\n\tdimensions: number;\n\tmaximumInputTokens: number;\n\tlocal = true;\n\tprivate endpoint: string;\n\tprivate apiKey?: string;\n\n\tconstructor(config: {\n\t\tendpoint: string;\n\t\tmodelId: string;\n\t\tdimensions: number;\n\t\tmaximumInputTokens?: number;\n\t\tapiKey?: string;\n\t}) {\n\t\tthis.endpoint = config.endpoint;\n\t\tthis.modelId = config.modelId;\n\t\tthis.dimensions = config.dimensions;\n\t\tthis.maximumInputTokens = config.maximumInputTokens ?? DEFAULT_MAX_TOKENS;\n\t\tthis.apiKey = config.apiKey;\n\t}\n\n\tassertLoopback(): void {\n\t\tlet host: string;\n\t\ttry {\n\t\t\thost = new URL(this.endpoint).hostname;\n\t\t} catch {\n\t\t\tthrow new Error(`Invalid embedding endpoint: ${this.endpoint}`);\n\t\t}\n\t\tif (![\"localhost\", \"127.0.0.1\", \"::1\"].includes(host)) {\n\t\t\tthrow new Error(`Refusing to send source embeddings to non-loopback endpoint: ${host}`);\n\t\t}\n\t}\n\n\tasync embed(request: EmbeddingRequest): Promise<EmbeddingResult> {\n\t\tthis.assertLoopback();\n\t\t// Batched to bounded size; tokens truncated deterministically upstream.\n\t\tconst response = await fetch(`${this.endpoint.replace(/\\/$/, \"\")}/v1/embeddings`, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: {\n\t\t\t\t\"content-type\": \"application/json\",\n\t\t\t\t...(this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}),\n\t\t\t},\n\t\t\tbody: JSON.stringify({ model: this.modelId, input: request.texts }),\n\t\t\tsignal: AbortSignal.timeout(30_000),\n\t\t});\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(`Embedding endpoint error: ${response.status}`);\n\t\t}\n\t\tconst data = (await response.json()) as {\n\t\t\tdata?: Array<{ embedding: number[] }>;\n\t\t};\n\t\tconst embeddings = (data.data ?? []).map((d) => d.embedding);\n\t\tif (embeddings.length !== request.texts.length) {\n\t\t\tthrow new Error(\"Embedding endpoint returned wrong number of vectors\");\n\t\t}\n\t\treturn { embeddings, modelId: this.modelId, dimensions: this.dimensions };\n\t}\n}\n\n/** A disabled backend that always throws — retrieval degrades to lexical/symbolic. */\nexport class DisabledEmbeddingBackend implements EmbeddingBackend {\n\tbackendId = \"disabled\";\n\tmodelId = \"disabled\";\n\tdimensions = 0;\n\tmaximumInputTokens = 0;\n\tlocal = true;\n\tasync embed(): Promise<EmbeddingResult> {\n\t\tthrow new Error(\"Embedding disabled for this workspace\");\n\t}\n}\n\n/** Resolve config into an active backend. */\nexport function resolveEmbeddingBackend(config?: EmbeddingConfig): EmbeddingBackend {\n\tconst mode = config?.mode ?? \"disabled\";\n\tif (mode === \"fixture\") {\n\t\treturn new FixtureEmbeddingBackend(config?.modelId ?? \"fixture-local\", config?.dimensions ?? DEFAULT_DIMENSIONS);\n\t}\n\tif (mode === \"local\") {\n\t\tif (!config?.endpoint) throw new Error(\"Local embedding requires an endpoint\");\n\t\treturn new LocalEmbeddingBackend({\n\t\t\tendpoint: config.endpoint,\n\t\t\tmodelId: config.modelId ?? \"local-embed\",\n\t\t\tdimensions: config.dimensions ?? DEFAULT_DIMENSIONS,\n\t\t\tmaximumInputTokens: config.maximumInputTokens,\n\t\t\tapiKey: config.apiKey,\n\t\t});\n\t}\n\tif (mode === \"remote\") {\n\t\tif (!config?.allowed) {\n\t\t\tthrow new Error(\"Remote embedding requires explicit policy authorization (allowed=true)\");\n\t\t}\n\t\tif (!config?.endpoint) throw new Error(\"Remote embedding requires an endpoint\");\n\t\treturn new RemoteEmbeddingBackend({\n\t\t\tendpoint: config.endpoint,\n\t\t\tmodelId: config.modelId ?? \"remote-embed\",\n\t\t\tdimensions: config.dimensions ?? DEFAULT_DIMENSIONS,\n\t\t\tapiKey: config.apiKey,\n\t\t});\n\t}\n\treturn new DisabledEmbeddingBackend();\n}\n\n/** Impacts an accurate per-chunk token estimate (rough, deterministic bound). */\nexport function estimateTokens(text: string): number {\n\t// ~4 chars/token heuristic.\n\treturn Math.max(1, Math.ceil(text.length / 4));\n}\n\n/** Deterministic token-aware truncation of embedding text. */\nexport function truncateForEmbedding(text: string, maxTokens: number): string {\n\tconst maxChars = maxTokens * 4;\n\tif (text.length <= maxChars) return text;\n\treturn `${text.slice(0, maxChars)}\\n…`;\n}\n\n/**\n * Opt-in remote OpenAI-compatible backend. Sends only bounded prepared text and,\n * by default, this is gated by explicit policy (allowed=true). Never reached\n * unless the operator explicitly configures a remote endpoint.\n */\nclass RemoteEmbeddingBackend implements EmbeddingBackend {\n\tbackendId = \"remote\";\n\tmodelId: string;\n\tdimensions: number;\n\tmaximumInputTokens: number;\n\tlocal = false;\n\tprivate endpoint: string;\n\tprivate apiKey?: string;\n\n\tconstructor(config: { endpoint: string; modelId: string; dimensions: number; apiKey?: string }) {\n\t\tthis.endpoint = config.endpoint;\n\t\tthis.modelId = config.modelId;\n\t\tthis.dimensions = config.dimensions;\n\t\tthis.maximumInputTokens = DEFAULT_MAX_TOKENS;\n\t\tthis.apiKey = config.apiKey;\n\t}\n\n\tasync embed(request: EmbeddingRequest): Promise<EmbeddingResult> {\n\t\tconst response = await fetch(`${this.endpoint.replace(/\\/$/, \"\")}/v1/embeddings`, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: {\n\t\t\t\t\"content-type\": \"application/json\",\n\t\t\t\t...(this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}),\n\t\t\t},\n\t\t\tbody: JSON.stringify({ model: this.modelId, input: request.texts }),\n\t\t\tsignal: AbortSignal.timeout(30_000),\n\t\t});\n\t\tif (!response.ok) throw new Error(`Remote embedding error: ${response.status}`);\n\t\tconst data = (await response.json()) as { data?: Array<{ embedding: number[] }> };\n\t\treturn {\n\t\t\tembeddings: (data.data ?? []).map((d) => d.embedding),\n\t\t\tmodelId: this.modelId,\n\t\t\tdimensions: this.dimensions,\n\t\t};\n\t}\n}\n"]}