@@ -57,11 +57,12 @@ import { IToolOrchestrator } from '../core/tools/IToolOrchestrator';
5757import { ToolExecutionRequestDetails } from '../core/tools/ToolExecutor' ;
5858import { ConversationMessage } from '../core/conversation/ConversationMessage' ;
5959import { GMIError , GMIErrorCode , createGMIErrorFromError } from '@framers/agentos/utils/errors' ;
60- import { GMIEventType , GMIEvent , SentimentHistoryState , createGMIEvent } from './GMIEvent.js' ;
60+ import { GMIEventType , SentimentHistoryState } from './GMIEvent.js' ;
6161import type { ICognitiveMemoryManager } from '../memory/CognitiveMemoryManager.js' ;
6262import type { AssembledMemoryContext } from '../memory/types.js' ;
6363import { ConversationHistoryManager } from './ConversationHistoryManager' ;
6464import { CognitiveMemoryBridge } from './CognitiveMemoryBridge' ;
65+ import { SentimentTracker } from './SentimentTracker' ;
6566
6667const DEFAULT_MAX_CONVERSATION_HISTORY_TURNS = 20 ;
6768const DEFAULT_SELF_REFLECTION_INTERVAL_TURNS = 5 ;
@@ -106,8 +107,7 @@ export class GMI implements IGMI {
106107 private memoryBridge : CognitiveMemoryBridge | null = null ;
107108
108109 // Sentiment & Event Tracking
109- private pendingGMIEvents : Set < GMIEventType > = new Set ( ) ;
110- private eventHistory : GMIEvent [ ] = [ ] ; // Last 20 events for debugging
110+ private sentimentTracker ! : SentimentTracker ;
111111 private metaPromptTriggerCounters : Map < string , number > = new Map ( ) ;
112112
113113 /**
@@ -174,6 +174,22 @@ export class GMI implements IGMI {
174174
175175 await this . loadStateFromMemoryAndPersona ( ) ;
176176
177+ // Initialize sentiment tracker
178+ this . sentimentTracker = new SentimentTracker (
179+ this . utilityAI ,
180+ this . workingMemory ,
181+ ( ) => this . activePersona ,
182+ ( ) => this . conversationHistoryManager . history ,
183+ ( ) => this . reasoningTrace . entries ,
184+ ( ) => this . currentUserContext ,
185+ async ( ctx ) => {
186+ this . currentUserContext = ctx ;
187+ await this . workingMemory . set ( 'currentUserContext' , this . currentUserContext ) ;
188+ } ,
189+ ( type , message , details ) => this . addTraceEntry ( type as ReasoningEntryType , message , details ) ,
190+ ( ) => this . gmiId ,
191+ ) ;
192+
177193 const reflectionMetaPrompt = this . activePersona . metaPrompts ?. find ( mp => mp . id === 'gmi_self_trait_adjustment' ) ;
178194 this . selfReflectionIntervalTurns = reflectionMetaPrompt ?. trigger ?. type === 'turn_interval' && typeof reflectionMetaPrompt . trigger . intervalTurns === 'number'
179195 ? reflectionMetaPrompt . trigger . intervalTurns
@@ -537,7 +553,7 @@ export class GMI implements IGMI {
537553 const userInputText = typeof lastMsg . content === 'string'
538554 ? lastMsg . content
539555 : JSON . stringify ( lastMsg . content ) ;
540- await this . analyzeTurnSentiment ( turnId , userInputText ) ;
556+ await this . sentimentTracker . analyzeTurnSentiment ( turnId , userInputText ) ;
541557 }
542558 }
543559
@@ -1117,309 +1133,6 @@ export class GMI implements IGMI {
11171133 }
11181134 }
11191135
1120- /**
1121- * Analyzes sentiment of user input and updates sentiment history.
1122- * Triggers event detection based on sentiment patterns.
1123- *
1124- * @param turnId - Current turn identifier
1125- * @param userInput - User's input text
1126- * @private
1127- */
1128- private async analyzeTurnSentiment ( turnId : string , userInput : string ) : Promise < void > {
1129- // Skip if input is not a string or is empty
1130- if ( ! userInput || typeof userInput !== 'string' ) {
1131- return ;
1132- }
1133-
1134- try {
1135- // Analyze sentiment using configurable method from sentimentTracking config
1136- const stConfig = this . activePersona . sentimentTracking ;
1137- const sentimentResult = await this . utilityAI . analyzeSentiment ( userInput , {
1138- method : stConfig ?. method || 'lexicon_based' ,
1139- modelId : stConfig ?. modelId || this . activePersona . defaultModelId ,
1140- providerId : stConfig ?. providerId || this . activePersona . defaultProviderId ,
1141- language : ( this . currentUserContext . language as string ) || 'en' ,
1142- } ) ;
1143-
1144- // Update UserContext with current sentiment
1145- this . currentUserContext . currentSentiment = sentimentResult . polarity ;
1146- await this . workingMemory . set ( 'currentUserContext' , this . currentUserContext ) ;
1147-
1148- // Get or initialize sentiment history
1149- let sentimentHistory = await this . workingMemory . get < SentimentHistoryState > (
1150- 'gmi_sentiment_history'
1151- ) ;
1152-
1153- if ( ! sentimentHistory ) {
1154- sentimentHistory = {
1155- trends : [ ] ,
1156- consecutiveFrustration : 0 ,
1157- consecutiveConfusion : 0 ,
1158- consecutiveSatisfaction : 0 ,
1159- } ;
1160- }
1161-
1162- // Add to sentiment trends
1163- const trend = {
1164- turnId,
1165- timestamp : new Date ( ) ,
1166- score : sentimentResult . score ,
1167- polarity : sentimentResult . polarity ,
1168- intensity : sentimentResult . intensity || 0 ,
1169- context : userInput . substring ( 0 , 100 ) , // First 100 chars
1170- } ;
1171-
1172- sentimentHistory . trends . push ( trend ) ;
1173-
1174- // Keep only last N trends (configurable sliding window)
1175- const historyWindow = stConfig ?. historyWindow || 10 ;
1176- if ( sentimentHistory . trends . length > historyWindow ) {
1177- sentimentHistory . trends . shift ( ) ;
1178- }
1179-
1180- // Update consecutive counters based on configurable thresholds
1181- const frustrationThreshold = stConfig ?. frustrationThreshold ?? - 0.3 ;
1182- const satisfactionThreshold = stConfig ?. satisfactionThreshold ?? 0.3 ;
1183-
1184- if ( sentimentResult . score < frustrationThreshold ) {
1185- // Negative sentiment
1186- sentimentHistory . consecutiveFrustration ++ ;
1187- sentimentHistory . consecutiveConfusion = 0 ;
1188- sentimentHistory . consecutiveSatisfaction = 0 ;
1189- } else if ( sentimentResult . score > satisfactionThreshold ) {
1190- // Positive sentiment
1191- sentimentHistory . consecutiveSatisfaction ++ ;
1192- sentimentHistory . consecutiveFrustration = 0 ;
1193- sentimentHistory . consecutiveConfusion = 0 ;
1194- } else {
1195- // Neutral sentiment (or close to it)
1196- sentimentHistory . consecutiveConfusion ++ ;
1197- sentimentHistory . consecutiveFrustration = 0 ;
1198- sentimentHistory . consecutiveSatisfaction = 0 ;
1199- }
1200-
1201- sentimentHistory . lastAnalyzedTurnId = turnId ;
1202-
1203- // Store updated sentiment history
1204- await this . workingMemory . set ( 'gmi_sentiment_history' , sentimentHistory ) ;
1205-
1206- this . addTraceEntry (
1207- ReasoningEntryType . DEBUG ,
1208- 'Turn sentiment analyzed' ,
1209- {
1210- sentiment : {
1211- score : sentimentResult . score ,
1212- polarity : sentimentResult . polarity ,
1213- intensity : sentimentResult . intensity ,
1214- } ,
1215- consecutiveCounters : {
1216- frustration : sentimentHistory . consecutiveFrustration ,
1217- confusion : sentimentHistory . consecutiveConfusion ,
1218- satisfaction : sentimentHistory . consecutiveSatisfaction ,
1219- } ,
1220- }
1221- ) ;
1222-
1223- // Detect and emit events based on sentiment patterns
1224- await this . detectAndEmitEvents ( turnId , userInput , sentimentResult , sentimentHistory ) ;
1225-
1226- } catch ( error : any ) {
1227- // Don't fail the turn if sentiment analysis fails - log and continue
1228- console . error ( `GMI (ID: ${ this . gmiId } ): Sentiment analysis error:` , error ) ;
1229- this . addTraceEntry (
1230- ReasoningEntryType . WARNING ,
1231- 'Sentiment analysis failed' ,
1232- { error : error . message }
1233- ) ;
1234- }
1235- }
1236-
1237- /**
1238- * Detects emotional patterns and emits appropriate GMI events.
1239- *
1240- * @param turnId - Current turn identifier
1241- * @param userInput - User's input text
1242- * @param sentimentResult - Sentiment analysis result
1243- * @param sentimentHistory - Historical sentiment data
1244- * @private
1245- */
1246- private async detectAndEmitEvents (
1247- turnId : string ,
1248- userInput : string ,
1249- sentimentResult : { score : number ; polarity : 'positive' | 'negative' | 'neutral' ; intensity ?: number ; negativeTokens ?: any [ ] } ,
1250- sentimentHistory : SentimentHistoryState
1251- ) : Promise < void > {
1252- // Read configurable thresholds
1253- const stConfig = this . activePersona . sentimentTracking ;
1254- const frustThreshold = stConfig ?. frustrationThreshold ?? - 0.3 ;
1255- const satisThreshold = stConfig ?. satisfactionThreshold ?? 0.3 ;
1256- const consecutiveRequired = stConfig ?. consecutiveTurnsForTrigger ?? 2 ;
1257-
1258- // Frustration detection
1259- if (
1260- ( sentimentResult . score < frustThreshold && ( sentimentResult . intensity || 0 ) > 0.6 ) ||
1261- sentimentHistory . consecutiveFrustration >= consecutiveRequired
1262- ) {
1263- this . emitEvent (
1264- createGMIEvent (
1265- GMIEventType . USER_FRUSTRATED ,
1266- turnId ,
1267- sentimentHistory . consecutiveFrustration >= 2 ? 'high' : 'medium' ,
1268- {
1269- sentimentScore : sentimentResult . score ,
1270- sentimentPolarity : sentimentResult . polarity ,
1271- sentimentIntensity : sentimentResult . intensity ,
1272- consecutiveTurns : sentimentHistory . consecutiveFrustration ,
1273- triggeredBy : 'sentiment' ,
1274- }
1275- )
1276- ) ;
1277- }
1278-
1279- // Confusion detection (keyword-based + sentiment)
1280- const confusionKeywords = [
1281- 'confused' ,
1282- "don't understand" ,
1283- "dont understand" ,
1284- 'unclear' ,
1285- 'what do you mean' ,
1286- 'explain' ,
1287- 'clarify' ,
1288- 'huh' ,
1289- '??' ,
1290- "doesn't make sense" ,
1291- "doesnt make sense" ,
1292- 'not sure' ,
1293- ] ;
1294-
1295- const lowerInput = userInput . toLowerCase ( ) ;
1296- const hasConfusionKeyword = confusionKeywords . some ( ( keyword ) =>
1297- lowerInput . includes ( keyword )
1298- ) ;
1299- const triggerKeywords = hasConfusionKeyword
1300- ? confusionKeywords . filter ( ( keyword ) => lowerInput . includes ( keyword ) )
1301- : [ ] ;
1302-
1303- if (
1304- hasConfusionKeyword ||
1305- ( sentimentResult . polarity === 'neutral' &&
1306- sentimentResult . negativeTokens &&
1307- sentimentResult . negativeTokens . length > 2 )
1308- ) {
1309- this . emitEvent (
1310- createGMIEvent (
1311- GMIEventType . USER_CONFUSED ,
1312- turnId ,
1313- sentimentHistory . consecutiveConfusion >= 2 ? 'high' : 'medium' ,
1314- {
1315- triggeredBy : hasConfusionKeyword ? 'keyword' : 'sentiment' ,
1316- consecutiveTurns : sentimentHistory . consecutiveConfusion ,
1317- evidencePreview : userInput . substring ( 0 , 100 ) ,
1318- triggerKeywords : hasConfusionKeyword ? triggerKeywords : undefined ,
1319- }
1320- )
1321- ) ;
1322- }
1323-
1324- // Satisfaction detection
1325- if (
1326- ( sentimentResult . score > satisThreshold && ( sentimentResult . intensity || 0 ) > 0.5 ) ||
1327- sentimentHistory . consecutiveSatisfaction >= ( consecutiveRequired + 1 )
1328- ) {
1329- this . emitEvent (
1330- createGMIEvent (
1331- GMIEventType . USER_SATISFIED ,
1332- turnId ,
1333- 'low' ,
1334- {
1335- sentimentScore : sentimentResult . score ,
1336- sentimentPolarity : sentimentResult . polarity ,
1337- sentimentIntensity : sentimentResult . intensity ,
1338- consecutiveTurns : sentimentHistory . consecutiveSatisfaction ,
1339- triggeredBy : 'sentiment' ,
1340- }
1341- )
1342- ) ;
1343- }
1344-
1345- // Error threshold detection (check reasoning trace for recent errors)
1346- const recentErrors = this . reasoningTrace . entries
1347- . slice ( - 10 )
1348- . filter ( ( entry ) => entry . type === ReasoningEntryType . ERROR ) ;
1349-
1350- if ( recentErrors . length >= 2 ) {
1351- this . emitEvent (
1352- createGMIEvent (
1353- GMIEventType . ERROR_THRESHOLD_EXCEEDED ,
1354- turnId ,
1355- 'high' ,
1356- {
1357- triggeredBy : 'error' ,
1358- errorCount : recentErrors . length ,
1359- consecutiveTurns : recentErrors . length ,
1360- }
1361- )
1362- ) ;
1363- }
1364-
1365- // Low engagement detection (consecutive neutral with short responses)
1366- const recentUserMessages = this . conversationHistoryManager . history
1367- . slice ( - 5 )
1368- . filter ( ( msg ) => msg . role === 'user' ) ;
1369-
1370- const avgLength = recentUserMessages . length > 0
1371- ? recentUserMessages . reduce (
1372- ( sum , msg ) => sum + String ( msg . content ) . length ,
1373- 0
1374- ) / recentUserMessages . length
1375- : 0 ;
1376-
1377- if ( sentimentHistory . consecutiveConfusion >= 4 && avgLength < 50 ) {
1378- this . emitEvent (
1379- createGMIEvent (
1380- GMIEventType . LOW_ENGAGEMENT ,
1381- turnId ,
1382- 'medium' ,
1383- {
1384- triggeredBy : 'pattern' ,
1385- consecutiveTurns : sentimentHistory . consecutiveConfusion ,
1386- evidencePreview : `Avg response length: ${ avgLength . toFixed ( 0 ) } chars` ,
1387- }
1388- )
1389- ) ;
1390- }
1391- }
1392-
1393- /**
1394- * Emits a GMI event and stores it in event history.
1395- *
1396- * @param event - The event to emit
1397- * @private
1398- */
1399- private emitEvent ( event : GMIEvent ) : void {
1400- // Add to pending events (will be consumed by trigger checking)
1401- this . pendingGMIEvents . add ( event . eventType ) ;
1402-
1403- // Add to event history for debugging (circular buffer, max 20)
1404- this . eventHistory . push ( event ) ;
1405- if ( this . eventHistory . length > 20 ) {
1406- this . eventHistory . shift ( ) ;
1407- }
1408-
1409- this . addTraceEntry (
1410- ReasoningEntryType . DEBUG ,
1411- `GMI Event Emitted: ${ event . eventType } ` ,
1412- {
1413- event : {
1414- eventType : event . eventType ,
1415- turnId : event . turnId ,
1416- severity : event . severity ,
1417- metadata : event . metadata ,
1418- } ,
1419- }
1420- ) ;
1421- }
1422-
14231136 /**
14241137 * Checks all metaprompt triggers (turn_interval, event_based, manual) and executes triggered ones.
14251138 *
@@ -1448,10 +1161,10 @@ export class GMI implements IGMI {
14481161 } else if ( metaPrompt . trigger . type === 'event_based' ) {
14491162 // NEW: Event-based trigger checking
14501163 const eventName = metaPrompt . trigger . eventName ;
1451- if ( this . pendingGMIEvents . has ( eventName as GMIEventType ) ) {
1164+ if ( this . sentimentTracker . pendingEvents . has ( eventName as GMIEventType ) ) {
14521165 triggeredMetaPrompts . push ( metaPrompt ) ;
14531166 // Consume the event (remove from pending)
1454- this . pendingGMIEvents . delete ( eventName as GMIEventType ) ;
1167+ this . sentimentTracker . pendingEvents . delete ( eventName as GMIEventType ) ;
14551168 }
14561169 } else if ( metaPrompt . trigger . type === 'manual' ) {
14571170 // NEW: Manual trigger checking
@@ -1703,7 +1416,7 @@ export class GMI implements IGMI {
17031416 ) ;
17041417
17051418 // Get the last event for confusion keywords
1706- const lastConfusionEvent = this . eventHistory
1419+ const lastConfusionEvent = this . sentimentTracker . events
17071420 . slice ( )
17081421 . reverse ( )
17091422 . find ( ( e ) => e . eventType === GMIEventType . USER_CONFUSED ) ;
0 commit comments