1- import aiConfig from '../../mcp/server/knowledge-base/config.mjs' ;
2- import TextEmbeddingService from '../memory-core/TextEmbeddingService.mjs' ;
3- import mcConfig from '../../mcp/server/memory-core/config.mjs' ;
4- import Base from '../../../src/core/Base.mjs' ;
1+ import aiConfig from '../../mcp/server/knowledge-base/config.mjs' ;
2+ import TextEmbeddingService from '../memory-core/TextEmbeddingService.mjs' ;
3+ import mcConfig from '../../mcp/server/memory-core/config.mjs' ;
4+ import Base from '../../../src/core/Base.mjs' ;
5+ import {
6+ bytesToTokens ,
7+ emitConsumerFriction
8+ } from '../memory-core/helpers/consumerFrictionHelper.mjs' ;
59import ChromaManager from './ChromaManager.mjs' ;
610import crypto from 'crypto' ;
711import fs from 'fs-extra' ;
@@ -147,10 +151,10 @@ class VectorService extends Base {
147151 */
148152 resolveTenantStamp ( tenantContext = { } ) {
149153 const config = this . getTenantIsolationConfig ( ) ;
150- const stamp = {
151- tenantId : tenantContext . tenantId ?? config . defaultTenantId ?? 'neo-shared' ,
152- repoSlug : tenantContext . repoSlug ?? config . defaultRepoSlug ?? 'neo' ,
153- visibility : tenantContext . visibility ?? config . defaultVisibility ?? 'team' ,
154+ const stamp = {
155+ tenantId : tenantContext . tenantId ?? config . defaultTenantId ,
156+ repoSlug : tenantContext . repoSlug ?? config . defaultRepoSlug ,
157+ visibility : tenantContext . visibility ?? config . defaultVisibility ,
154158 tenantConfigVersion : tenantContext . configVersion ?? 0 ,
155159 ingestedAt : Date . now ( )
156160 } ;
@@ -211,9 +215,9 @@ class VectorService extends Base {
211215 field,
212216 chunkId,
213217 clientValue,
214- serverValue : serverValue ?? null ,
215- tenantId : stamp . tenantId ,
216- repoSlug : stamp . repoSlug ,
218+ serverValue : serverValue ?? null ,
219+ tenantId : stamp . tenantId ,
220+ repoSlug : stamp . repoSlug ,
217221 originAgentIdentity : stamp . originAgentIdentity ?? null
218222 } ;
219223
@@ -275,31 +279,134 @@ class VectorService extends Base {
275279 return deleteStale ? 'delete-upfront' : STALE_STRATEGY_SKIP ;
276280 }
277281
282+ /**
283+ * Resolves the local embedding-model guardrail used before provider invocation.
284+ *
285+ * @returns {{enabled: Boolean, contextLimitTokens: Number, safeProcessingLimitTokens: Number, model: String} }
286+ */
287+ resolveEmbeddingGuardrail ( ) {
288+ const localEmbeddingProviders = new Set ( [ 'openAiCompatible' , 'ollama' ] ) ;
289+ const embeddingProvider = mcConfig . embeddingProvider ;
290+ const contextLimitTokens = Number ( aiConfig . localModels . embedding . contextLimitTokens ) ;
291+ const safeProcessingLimitTokens = Number ( aiConfig . localModels . embedding . safeProcessingLimitTokens ) ;
292+ const model = embeddingProvider === 'ollama'
293+ ? aiConfig . ollama . embeddingModel
294+ : embeddingProvider === 'openAiCompatible'
295+ ? aiConfig . openAiCompatible . embeddingModel
296+ : embeddingProvider ;
297+
298+ return {
299+ enabled : localEmbeddingProviders . has ( embeddingProvider ) ,
300+ contextLimitTokens,
301+ safeProcessingLimitTokens,
302+ model
303+ } ;
304+ }
305+
306+ /**
307+ * Emits a bounded, non-secret friction record for an over-budget embedding input.
308+ *
309+ * @param {Object } options
310+ * @param {Object } options.chunk Tenant-stamped chunk.
311+ * @param {String } options.text Provider input text.
312+ * @param {Object } options.guardrail Resolved embedding guardrail.
313+ * @returns {{skip: Boolean, inputBytes: Number, inputTokensEstimate: Number} }
314+ */
315+ evaluateEmbeddingInput ( { chunk, text, guardrail} ) {
316+ if ( ! guardrail . enabled ) {
317+ return { skip : false , inputBytes : 0 , inputTokensEstimate : 0 } ;
318+ }
319+
320+ const inputBytes = Buffer . byteLength ( text || '' , 'utf8' ) ;
321+ const inputTokensEstimate = bytesToTokens ( inputBytes ) ;
322+
323+ if ( inputTokensEstimate <= guardrail . safeProcessingLimitTokens ) {
324+ return { skip : false , inputBytes, inputTokensEstimate} ;
325+ }
326+
327+ emitConsumerFriction ( {
328+ assetRef : chunk . id || chunk . source || chunk . name || 'kb-chunk' ,
329+ consumer : 'VectorService.embedChunks' ,
330+ model : guardrail . model ,
331+ symptom : 'size-precheck-skip' ,
332+ emissionPoint : 'pre-invocation' ,
333+ suggestionKind : 'split-document' ,
334+ inputBytes,
335+ inputTokensEstimate,
336+ contextLimitTokens : guardrail . contextLimitTokens ,
337+ safeProcessingLimitTokens : guardrail . safeProcessingLimitTokens ,
338+ serviceDomain : 'other' ,
339+ note : 'KB embedding input exceeds safe processing band; split or reduce the source chunk before embedding.'
340+ } ) ;
341+
342+ logger . warn ( '[VectorService] Skipping over-budget embedding chunk before provider invocation.' , {
343+ chunkId : chunk . id ,
344+ tenantId : chunk . tenantId ,
345+ repoSlug : chunk . repoSlug ,
346+ source : chunk . source ,
347+ inputBytes,
348+ inputTokensEstimate,
349+ safeProcessingLimitTokens : guardrail . safeProcessingLimitTokens ,
350+ contextLimitTokens : guardrail . contextLimitTokens
351+ } ) ;
352+
353+ return { skip : true , inputBytes, inputTokensEstimate} ;
354+ }
355+
278356 /**
279357 * Embeds a set of chunks into the provided Chroma collection.
280358 *
281359 * @param {Object } options
282360 * @param {Object } options.collection Chroma collection target.
283361 * @param {Object[] } options.chunksToProcess Tenant-stamped chunks to embed.
284- * @returns {Promise<void > }
362+ * @returns {Promise<{embedded: Number, skipped: Number} > }
285363 */
286364 async embedChunks ( { collection, chunksToProcess} ) {
287365 if ( chunksToProcess . length === 0 ) {
288- return ;
366+ return { embedded : 0 , skipped : 0 } ;
289367 }
290368
291369 logger . log ( `Using TextEmbeddingService with provider: ${ mcConfig . embeddingProvider } .` ) ;
292370 logger . log ( 'Embedding chunks...' ) ;
293371
294372 const { batchSize, batchDelay, maxRetries} = aiConfig ;
373+ const guardrail = this . resolveEmbeddingGuardrail ( ) ;
374+ let embeddedCount = 0 ;
375+ let skippedCount = 0 ;
295376
296377 for ( let i = 0 ; i < chunksToProcess . length ; i += batchSize ) {
297378 if ( i > 0 && batchDelay ) {
298379 await this . timeout ( batchDelay ) ;
299380 }
300381
301- const batch = chunksToProcess . slice ( i , i + batchSize ) ;
302- const textsToEmbed = batch . map ( chunk => `${ chunk . type } : ${ chunk . name } in ${ chunk . className || '' } \n${ chunk . description || chunk . content || '' } ` ) ;
382+ const batch = chunksToProcess . slice ( i , i + batchSize ) ;
383+ const batchInputs = batch . map ( chunk => ( {
384+ chunk,
385+ text : `${ chunk . type } : ${ chunk . name } in ${ chunk . className || '' } \n${ chunk . description || chunk . content || '' } `
386+ } ) ) ;
387+ const embeddable = [ ] ;
388+
389+ for ( const input of batchInputs ) {
390+ const result = this . evaluateEmbeddingInput ( {
391+ chunk : input . chunk ,
392+ text : input . text ,
393+ guardrail
394+ } ) ;
395+
396+ if ( result . skip ) {
397+ skippedCount ++ ;
398+ } else {
399+ embeddable . push ( input ) ;
400+ }
401+ }
402+
403+ if ( embeddable . length === 0 ) {
404+ logger . warn ( `[VectorService] Skipped embedding batch ${ i / batchSize + 1 } ; all ${ batch . length } chunk(s) exceeded the embedding safe-processing band.` ) ;
405+ continue ;
406+ }
407+
408+ const batchToEmbed = embeddable . map ( input => input . chunk ) ;
409+ const textsToEmbed = embeddable . map ( input => input . text ) ;
303410
304411 let retries = 0 ;
305412 let success = false ;
@@ -308,7 +415,7 @@ class VectorService extends Base {
308415 try {
309416 const embeddings = await TextEmbeddingService . embedTexts ( textsToEmbed , mcConfig . embeddingProvider ) ;
310417
311- const metadatas = batch . map ( chunk => {
418+ const metadatas = batchToEmbed . map ( chunk => {
312419 const metadata = { } ;
313420 for ( const [ key , value ] of Object . entries ( chunk ) ) {
314421 metadata [ key ] = ( value === null ) ? 'null' : ( typeof value === 'object' ) ? JSON . stringify ( value ) : value ;
@@ -317,12 +424,13 @@ class VectorService extends Base {
317424 } ) ;
318425
319426 await collection . upsert ( {
320- ids : batch . map ( chunk => chunk . id ) ,
427+ ids : batchToEmbed . map ( chunk => chunk . id ) ,
321428 embeddings,
322429 metadatas
323430 } ) ;
324431
325- logger . log ( `Processed and embedded batch ${ i / batchSize + 1 } of ${ Math . ceil ( chunksToProcess . length / batchSize ) } ` ) ;
432+ embeddedCount += batchToEmbed . length ;
433+ logger . log ( `Processed and embedded batch ${ i / batchSize + 1 } of ${ Math . ceil ( chunksToProcess . length / batchSize ) } (${ batchToEmbed . length } embedded, ${ batch . length - batchToEmbed . length } skipped).` ) ;
326434 success = true ;
327435 } catch ( err ) {
328436 retries ++ ;
@@ -335,6 +443,8 @@ class VectorService extends Base {
335443 }
336444 }
337445 }
446+
447+ return { embedded : embeddedCount , skipped : skippedCount } ;
338448 }
339449
340450 /**
@@ -377,7 +487,11 @@ class VectorService extends Base {
377487 let shadowPromoted = false ;
378488
379489 try {
380- await this . embedChunks ( { collection : shadowCollection , chunksToProcess : knowledgeBase } ) ;
490+ const embedResult = await this . embedChunks ( { collection : shadowCollection , chunksToProcess : knowledgeBase } ) ;
491+
492+ if ( embedResult . skipped > 0 ) {
493+ throw new Error ( `KB_EMBEDDING_INPUT_SIZE_EXCEEDED: shadow-swap refused to promote an incomplete corpus after skipping ${ embedResult . skipped } over-budget embedding chunk(s).` ) ;
494+ }
381495
382496 logger . log ( `Promoting shadow collection '${ shadowName } ' to '${ aiConfig . collectionName } '.` ) ;
383497 await liveCollection . modify ( { name : parkingName } ) ;
@@ -394,7 +508,7 @@ class VectorService extends Base {
394508
395509 return {
396510 message,
397- embedded : knowledgeBase . length ,
511+ embedded : embedResult . embedded ,
398512 deleted : idsToDeleteCount ,
399513 staleStrategy : 'shadow-swap' ,
400514 shadowCollection : shadowName ,
@@ -498,16 +612,16 @@ class VectorService extends Base {
498612 knowledgeBase . forEach ( chunk => {
499613 if ( chunk . kind === 'module-context' && chunk . className ) {
500614 classNameToDataMap [ chunk . className ] = {
501- source : chunk . source ,
502- parent : chunk . extends || null
615+ source : chunk . source ,
616+ parent : chunk . extends || null
503617 } ;
504618 }
505619 } ) ;
506620
507621 knowledgeBase . forEach ( chunk => {
508- let currentClass = chunk . className ; // Metadata is now on every chunk
622+ let currentClass = chunk . className ; // Metadata is now on every chunk
509623 const inheritanceChain = [ ] ;
510- const visited = new Set ( ) ;
624+ const visited = new Set ( ) ;
511625
512626 // If no className metadata (e.g. non-class files), skip
513627 if ( ! currentClass ) return ;
@@ -529,8 +643,8 @@ class VectorService extends Base {
529643
530644 logger . log ( 'Fetching existing documents from ChromaDB...' ) ;
531645 const existingIds = new Set ( ) ;
532- let offset = 0 ;
533- const limit = 2000 ;
646+ let offset = 0 ;
647+ const limit = 2000 ;
534648 let batch ;
535649
536650 // ChromaDB has a default limit (usually 10) if not specified.
@@ -539,7 +653,7 @@ class VectorService extends Base {
539653 batch = await collection . get ( {
540654 include : [ ] ,
541655 limit,
542- offset : offset
656+ offset : offset
543657 } ) ;
544658
545659 batch . ids . forEach ( id => existingIds . add ( id ) ) ;
@@ -593,7 +707,7 @@ class VectorService extends Base {
593707 if ( viaMcp && workVolume > mcpThreshold ) {
594708 // `logPath` is a Provider-owned leaf; read it directly so malformed config
595709 // shape fails loud instead of silently re-deriving a local default.
596- const logDir = aiConfig . logPath ;
710+ const logDir = aiConfig . logPath ;
597711 const errorPayload = {
598712 error : `KB sync work volume exceeds MCP-callable threshold` ,
599713 message : `${ workVolume } chunks need re-embedding (threshold: ${ mcpThreshold } ). ` +
@@ -610,7 +724,7 @@ class VectorService extends Base {
610724
611725 if ( shouldShadowSwap ) {
612726 return await this . embedViaShadowSwap ( {
613- liveCollection : collection ,
727+ liveCollection : collection ,
614728 knowledgeBase,
615729 idsToDeleteCount : idsToDelete . length
616730 } ) ;
@@ -621,12 +735,12 @@ class VectorService extends Base {
621735 logger . log ( `Deleted ${ idsToDelete . length } stale chunks.` ) ;
622736 }
623737
624- await this . embedChunks ( { collection, chunksToProcess} ) ;
738+ const embedResult = await this . embedChunks ( { collection, chunksToProcess} ) ;
625739
626- const count = await collection . count ( ) ;
740+ const count = await collection . count ( ) ;
627741 const message = `Embedding complete. Collection now contains ${ count } items.` ;
628742 logger . log ( message ) ;
629- return { message, embedded : chunksToProcess . length , deleted : idsToDelete . length } ;
743+ return { message, embedded : embedResult . embedded , deleted : idsToDelete . length } ;
630744 }
631745
632746 /**
0 commit comments