1- import fs from 'fs' ;
2- import path from 'path' ;
3- import aiConfig from '../../mcp/server/memory-core/config.mjs' ;
4- import Base from '../../../src/core/Base.mjs' ;
5- import { emitConsumerFriction , invokeWithGuardrail } from '../../services/memory-core/helpers/consumerFrictionHelper.mjs' ;
1+ import fs from 'fs' ;
2+ import path from 'path' ;
3+ import aiConfig from '../../mcp/server/memory-core/config.mjs' ;
4+ import Base from '../../../src/core/Base.mjs' ;
5+ import {
6+ bytesToTokens ,
7+ emitConsumerFriction ,
8+ invokeWithGuardrail
9+ } from '../../services/memory-core/helpers/consumerFrictionHelper.mjs' ;
610import GraphService from '../../services/memory-core/GraphService.mjs' ;
711import Json from '../../../src/util/Json.mjs' ;
812import logger from '../../mcp/server/memory-core/logger.mjs' ;
@@ -57,6 +61,105 @@ class SemanticGraphExtractor extends Base {
5761 return this . isMemorySessionGraphNodeId ( id ) ? GraphService . normalizeGraphNodeId ( id ) : id ;
5862 }
5963
64+ /**
65+ * Estimates chat-message payload size using the shared consumer-friction token heuristic.
66+ *
67+ * @summary Anchor & Echo: Keeps post-invocation retry sizing on the same estimator as the
68+ * pre-invocation guardrail, so calibration fixes land in one place.
69+ *
70+ * @param {Object[] } messages Provider chat messages
71+ * @returns {Object } `{text, bytes, tokens}` estimate for the composed provider payload
72+ * @protected
73+ */
74+ estimateChatMessagesPayload ( messages ) {
75+ const text = messages . map ( m => m . content ) . join ( '\n' ) ,
76+ bytes = Buffer . byteLength ( text , 'utf8' ) ;
77+
78+ return {
79+ text,
80+ bytes,
81+ tokens : bytesToTokens ( bytes )
82+ } ;
83+ }
84+
85+ /**
86+ * Resolves provider completion finish reasons across supported raw envelopes.
87+ *
88+ * @summary Anchor & Echo: Normalizes OpenAI-compatible `finish_reason`, Ollama
89+ * `done_reason`, and Gemini `finishReason` shapes into one retry-loop predicate.
90+ *
91+ * @param {Object } result Provider generation result
92+ * @returns {String } Completion finish reason, or an empty string when unavailable
93+ * @protected
94+ */
95+ getCompletionFinishReason ( result ) {
96+ const reason = result ?. finish_reason ??
97+ result ?. finishReason ??
98+ result ?. raw ?. finish_reason ??
99+ result ?. raw ?. finishReason ??
100+ result ?. raw ?. done_reason ??
101+ result ?. raw ?. doneReason ??
102+ result ?. raw ?. choices ?. [ 0 ] ?. finish_reason ??
103+ result ?. raw ?. choices ?. [ 0 ] ?. finishReason ??
104+ result ?. raw ?. candidates ?. [ 0 ] ?. finishReason ;
105+
106+ return typeof reason === 'string' ? reason : '' ;
107+ }
108+
109+ /**
110+ * Tests whether a provider finish reason means the response hit an output/token cap.
111+ *
112+ * @summary Anchor & Echo: Length-capped non-empty responses are treated as overflow
113+ * evidence because schema-repair retries would append the truncated body and grow the prompt.
114+ *
115+ * @param {String } finishReason Provider finish reason
116+ * @returns {Boolean } `true` when the reason indicates token-length truncation
117+ * @protected
118+ */
119+ isLengthTruncatedCompletion ( finishReason ) {
120+ return / ^ ( l e n g t h | m a x _ t o k e n s | t o k e n _ l i m i t ) $ / i. test ( String ( finishReason || '' ) . trim ( ) ) ;
121+ }
122+
123+ /**
124+ * Emits deterministic context-overflow friction for post-invocation retry aborts.
125+ *
126+ * @summary Anchor & Echo: Reuses the established ConsumerFriction channel for retry-loop
127+ * overflow evidence instead of introducing a parallel symptom path.
128+ *
129+ * @param {Object } options
130+ * @param {String } options.sessionId Session identifier
131+ * @param {String } options.consumerModel Provider model identifier
132+ * @param {Number } options.consumerContextTokens Context window in tokens
133+ * @param {Number } options.consumerSafeTokens Safe processing band in tokens
134+ * @param {Number } options.inputBytes Estimated prompt bytes
135+ * @param {Number } options.inputTokensEstimate Estimated prompt tokens
136+ * @param {String } options.note Diagnostic note
137+ * @protected
138+ */
139+ emitRetryLoopContextOverflow ( {
140+ sessionId,
141+ consumerModel,
142+ consumerContextTokens,
143+ consumerSafeTokens,
144+ inputBytes,
145+ inputTokensEstimate,
146+ note
147+ } ) {
148+ emitConsumerFriction ( {
149+ symptom : 'context-overflow' ,
150+ consumer : 'SemanticGraphExtractor' ,
151+ model : consumerModel ,
152+ assetRef : sessionId ,
153+ serviceDomain : 'dream-pipeline' ,
154+ emissionPoint : 'post-invocation-failure' ,
155+ inputBytes,
156+ inputTokensEstimate,
157+ contextLimitTokens : consumerContextTokens ,
158+ safeProcessingLimitTokens : consumerSafeTokens ,
159+ note
160+ } ) ;
161+ }
162+
60163 /**
61164 * Executes the Tri-Vector Synthesis (Semantic Graph, Open Deltas, Roadmap Strategy)
62165 * from the session memory log via JSON schema extraction.
@@ -128,7 +231,7 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
128231
129232 try {
130233 const graphProvider = resolveGraphModelProvider ( aiConfig ) ;
131- const provider = buildGraphProvider ( {
234+ const provider = buildGraphProvider ( {
132235 modelProvider : graphProvider ,
133236 ollamaConfig : aiConfig . ollama ,
134237 openAiCompatibleConfig : aiConfig . openAiCompatible
@@ -141,9 +244,9 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
141244 ] ;
142245
143246 let maxRetries = 3 ;
144- let attempt = 0 ;
145- let payload = null ;
146- let result = null ;
247+ let attempt = 0 ;
248+ let payload = null ;
249+ let result = null ;
147250
148251 // Wrap each LLM invocation with the Consumer-Friction guardrail. The upstream
149252 // pre-check skips invocation when the composed messages' estimated token count
@@ -157,17 +260,20 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
157260 // threshold (model-role axis, not provider-namespace — remote providers like
158261 // Gemini are API-bound and don't expose these knobs; local providers
159262 // share the same caps because the limit comes from the loaded model).
160- const consumerModel = aiConfig [ graphProvider ] . model ;
161- const consumerContextTokens = aiConfig . localModels . chat . contextLimitTokens ;
162- const consumerSafeTokens = aiConfig . localModels . chat . safeProcessingLimitTokens ;
263+ const consumerModel = aiConfig [ graphProvider ] . model ;
264+ const consumerContextTokens = aiConfig . localModels . chat . contextLimitTokens ;
265+ const configuredSafeTokens = aiConfig . localModels . chat . safeProcessingLimitTokens ;
266+ const consumerSafeTokens = Number . isFinite ( configuredSafeTokens )
267+ ? configuredSafeTokens
268+ : Math . floor ( consumerContextTokens * 0.75 ) ;
163269
164270 // Per-task no-think + grammar-constrained tri-vector output. `graphReasoningEffort`
165271 // (default 'none') disables the gemma MoE's hidden thinking pass; `triVectorSchema` enforces
166272 // the A2A session_artifact/graph shape via the provider's json_schema path, which makes the
167273 // repair-retry loop below a safety net rather than the happy path. Schema mirrors the strict
168274 // shape declared in the systemInstruction above.
169275 const graphReasoningEffort = aiConfig . localModels . chat . graphReasoningEffort ;
170- const triVectorSchema = {
276+ const triVectorSchema = {
171277 type : 'object' ,
172278 properties : {
173279 a2a_version : { type : 'string' } ,
@@ -217,11 +323,13 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
217323 } ,
218324 required : [ 'a2a_version' , 'session_artifact' ]
219325 } ;
326+ const repairFeedback = `Your previous response failed internal schema validation. You are missing required keys (e.g., session_artifact) or you provided malformed JSON. Please correct your output and provide ONLY the exact JSON shape requested in the instructions.` ;
220327
221328 while ( attempt < maxRetries && ! payload ) {
222329 attempt ++ ;
223330
224- const inputPayloadText = messages . map ( m => m . content ) . join ( '\n' ) ;
331+ const inputPayload = this . estimateChatMessagesPayload ( messages ) ;
332+ const inputPayloadText = inputPayload . text ;
225333 const guardrailed = await invokeWithGuardrail ( {
226334 invocationFn : ( ) => provider . generate ( messages , { reasoning_effort : graphReasoningEffort || undefined , responseSchema : triVectorSchema , responseSchemaName : 'triVector' } ) ,
227335 inputPayload : inputPayloadText ,
@@ -240,6 +348,23 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
240348 }
241349
242350 result = guardrailed . result ;
351+ const finishReason = this . getCompletionFinishReason ( result ) ;
352+
353+ if ( this . isLengthTruncatedCompletion ( finishReason ) ) {
354+ logger . warn ( `[SemanticGraphExtractor] Attempt ${ attempt } : Provider reported '${ finishReason } ' for session ${ session . meta . sessionId } ; classifying as context-overflow and aborting JSON repair loop.` ) ;
355+
356+ this . emitRetryLoopContextOverflow ( {
357+ sessionId : session . meta . sessionId ,
358+ consumerModel,
359+ consumerContextTokens,
360+ consumerSafeTokens,
361+ inputBytes : inputPayload . bytes ,
362+ inputTokensEstimate : inputPayload . tokens ,
363+ note : `Provider finish_reason='${ finishReason } ' before schema validation. Aborting repair loop to avoid appending a truncated response. Attempt ${ attempt } /${ maxRetries } .`
364+ } ) ;
365+
366+ return null ;
367+ }
243368
244369 // Silent context-overflow detection: provider can stream-close immediately
245370 // with an empty body when its loaded-model context window is smaller than
@@ -276,12 +401,32 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
276401 logger . warn ( `[SemanticGraphExtractor] Attempt ${ attempt } : Failed to validate extracted Tri-Vector A2A payload for session: ${ session . meta . sessionId } ` ) ;
277402
278403 if ( attempt < maxRetries ) {
404+ const repairMessages = [
405+ ...messages ,
406+ { role : 'assistant' , content : result . content } ,
407+ { role : 'user' , content : repairFeedback }
408+ ] ;
409+ const repairPayload = this . estimateChatMessagesPayload ( repairMessages ) ;
410+
411+ if ( repairPayload . tokens > consumerSafeTokens ) {
412+ logger . warn ( `[SemanticGraphExtractor] Attempt ${ attempt } : JSON repair prompt would exceed safe processing band for session ${ session . meta . sessionId } ; classifying as context-overflow and aborting retry loop.` ) ;
413+
414+ this . emitRetryLoopContextOverflow ( {
415+ sessionId : session . meta . sessionId ,
416+ consumerModel,
417+ consumerContextTokens,
418+ consumerSafeTokens,
419+ inputBytes : repairPayload . bytes ,
420+ inputTokensEstimate : repairPayload . tokens ,
421+ note : `Repair retry prompt estimate ${ repairPayload . tokens } tokens exceeds safe band ${ consumerSafeTokens } . Aborting instead of appending assistant output and repair feedback. Attempt ${ attempt } /${ maxRetries } .`
422+ } ) ;
423+
424+ return null ;
425+ }
426+
279427 logger . warn ( `[SemanticGraphExtractor] Attempt ${ attempt } : Injecting autonomous JSON repair feedback loop.` ) ;
280- messages . push ( { role : 'assistant' , content : result . content } ) ;
281- messages . push ( {
282- role : 'user' ,
283- content : `Your previous response failed internal schema validation. You are missing required keys (e.g., session_artifact) or you provided malformed JSON. Please correct your output and provide ONLY the exact JSON shape requested in the instructions.`
284- } ) ;
428+ messages . push ( repairMessages [ repairMessages . length - 2 ] ) ;
429+ messages . push ( repairMessages [ repairMessages . length - 1 ] ) ;
285430 payload = null ; // Ensure loop continues
286431 } else {
287432 logger . warn ( `[SemanticGraphExtractor] --- FINAL EXHAUSTED RAW LLM DUMP ---\n${ result . content } \n-----------------------------` ) ;
@@ -326,7 +471,7 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
326471 if ( node . id === 'frontier' ) continue ;
327472
328473 let nodeType = node . type && VALID_TYPES . includes ( node . type . toUpperCase ( ) ) ? node . type . toUpperCase ( ) : 'CONCEPT' ;
329- let nodeId = node . id ;
474+ let nodeId = node . id ;
330475
331476 // Enforce Neo native Graph ID specification (Type:Name) if hallucinated
332477 if ( ! nodeId . includes ( ':' ) ) {
@@ -376,7 +521,7 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
376521 const targetExists = validNodeRefs . has ( resolvedTarget ) || GraphService . db . nodes . has ( resolvedTarget ) ;
377522
378523 if ( ! sourceExists || ! targetExists ) {
379- const isProvenance = [ 'MENTIONED_IN' , 'DISCUSSED_IN' , 'REFERENCED_BY' ] . includes ( edge . relationship ) ;
524+ const isProvenance = [ 'MENTIONED_IN' , 'DISCUSSED_IN' , 'REFERENCED_BY' ] . includes ( edge . relationship ) ;
380525 const targetsSessionOrMemory = this . isMemorySessionGraphNodeId ( resolvedTarget ) || this . isMemorySessionGraphNodeId ( resolvedSource ) ;
381526
382527 if ( isProvenance && targetsSessionOrMemory ) {
@@ -389,7 +534,7 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
389534 */
390535 logger . info ( `[SemanticGraphExtractor] Queuing unresolved provenance edge for lazy back-fill: ${ resolvedSource } -> ${ resolvedTarget } ` ) ;
391536 const lazyQueueFile = aiConfig . lazyEdgesQueuePath ;
392- const edgeData = JSON . stringify ( { ...edge , source : resolvedSource , target : resolvedTarget , timestamp : new Date ( ) . toISOString ( ) } ) + '\n' ;
537+ const edgeData = JSON . stringify ( { ...edge , source : resolvedSource , target : resolvedTarget , timestamp : new Date ( ) . toISOString ( ) } ) + '\n' ;
393538 try {
394539 // Ensure the directory exists before appending
395540 await fs . promises . mkdir ( path . dirname ( lazyQueueFile ) , { recursive : true } ) ;
@@ -478,7 +623,7 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
478623
479624 try {
480625 const graphProvider = resolveGraphModelProvider ( aiConfig ) ;
481- const provider = buildGraphProvider ( {
626+ const provider = buildGraphProvider ( {
482627 modelProvider : graphProvider ,
483628 ollamaConfig : aiConfig . ollama ,
484629 openAiCompatibleConfig : aiConfig . openAiCompatible
@@ -490,8 +635,8 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
490635 ] ;
491636
492637 let maxRetries = 2 ;
493- let attempt = 0 ;
494- let payload = null ;
638+ let attempt = 0 ;
639+ let payload = null ;
495640
496641 while ( attempt < maxRetries && ! payload ) {
497642 attempt ++ ;
0 commit comments