devcontext
Version:
DevContext is a cutting-edge Model Context Protocol (MCP) server designed to provide developers with continuous, project-centric context awareness.
832 lines (758 loc) • 25.8 kB
JavaScript
/**
* ConversationIntelligence.js
*
* Provides logic for recording and analyzing conversation messages,
* including semantic markers and sentiment indicators.
*/
import { executeQuery } from "../db.js";
import { v4 as uuidv4 } from "uuid";
import * as TextTokenizerLogic from "./TextTokenizerLogic.js";
import * as ContextIndexerLogic from "./ContextIndexerLogic.js";
import * as ConversationSegmenter from "./ConversationSegmenter.js";
import * as ConversationPurposeDetector from "./ConversationPurposeDetector.js";
import * as ContextCompressorLogic from "./ContextCompressorLogic.js";
import { logMessage } from "../utils/logger.js";
/**
* Records a message in the conversation history, extracting semantic markers and sentiment indicators.
*
* @param {string} messageContent - The content of the message
* @param {string} role - The role of the sender (e.g., 'user', 'assistant')
* @param {string} conversationId - The conversation ID
* @param {string[]} [relatedContextEntityIds=[]] - Array of related context entity IDs
* @param {string} [topicSegmentId] - Optional topic segment ID
* @param {string} [userIntent] - Optional user intent for the message
* @returns {Promise<string>} The ID of the recorded message
*/
export async function recordMessage(
messageContent,
role,
conversationId,
relatedContextEntityIds = [],
topicSegmentId,
userIntent
) {
try {
// 1. Generate message_id
const message_id = uuidv4();
const timestamp = new Date().toISOString();
// Print detailed input parameters for debugging
logMessage("info", "===== RECORD MESSAGE - START =====");
logMessage("info", "Input parameters:");
logMessage("info", "- message_id:", message_id);
logMessage("info", "- conversation_id:", conversationId);
logMessage("info", "- role:", role);
logMessage(
"info",
"- content:",
messageContent &&
messageContent.substring(0, 50) +
(messageContent.length > 50 ? "..." : "")
);
logMessage("info", "- timestamp:", timestamp);
logMessage("info", "- topic_segment_id:", topicSegmentId || "null");
logMessage("info", "- user_intent:", userIntent || "null");
logMessage(
"info",
"- related_context_entity_ids:",
JSON.stringify(relatedContextEntityIds || [])
);
// 2. Extract semantic markers (e.g., idioms, emphasis, question, etc.)
let semantic_markers = [];
if (role === "user" && TextTokenizerLogic.identifyLanguageSpecificIdioms) {
// Always provide a language parameter, defaulting to "plaintext" if none is detected
semantic_markers =
TextTokenizerLogic.identifyLanguageSpecificIdioms(
messageContent,
"plaintext"
) || [];
} else {
// Fallback: simple keyword spotting for emphasis/questions
if (messageContent.includes("!")) semantic_markers.push("emphasis");
if (messageContent.includes("?")) semantic_markers.push("question");
}
// 3. Extract sentiment indicators (basic regex for positive/negative keywords)
const positiveKeywords = [
"great",
"good",
"excellent",
"awesome",
"love",
"like",
"well done",
"thanks",
"thank you",
"perfect",
"amazing",
"fantastic",
"nice",
"happy",
"success",
"yay",
];
const negativeKeywords = [
"bad",
"error",
"fail",
"hate",
"problem",
"issue",
"bug",
"broken",
"wrong",
"difficult",
"hard",
"annoy",
"frustrate",
"sad",
"unhappy",
"disappoint",
"no",
"not working",
"doesn't work",
"crash",
"stuck",
];
const foundPositive = positiveKeywords.filter((kw) =>
messageContent.toLowerCase().includes(kw)
);
const foundNegative = negativeKeywords.filter((kw) =>
messageContent.toLowerCase().includes(kw)
);
const sentiment_indicators = {
positive_keywords: foundPositive,
negative_keywords: foundNegative,
};
// Create the message object to be indexed
const messageObject = {
message_id,
conversation_id: conversationId,
role,
content: messageContent,
timestamp,
relatedContextEntityIds: JSON.stringify(relatedContextEntityIds || []),
summary: null,
userIntent: userIntent || null,
topicSegmentId: topicSegmentId || null,
semantic_markers: JSON.stringify(semantic_markers),
sentiment_indicators: JSON.stringify(sentiment_indicators),
};
logMessage("info", "Message object to be indexed:", {
message_id: messageObject.message_id,
conversation_id: messageObject.conversation_id,
role: messageObject.role,
topic_segment_id: messageObject.topicSegmentId,
user_intent: messageObject.userIntent,
});
// 4. Call ContextIndexerLogic.indexConversationMessage
await ContextIndexerLogic.indexConversationMessage(messageObject);
logMessage("info", "===== RECORD MESSAGE - COMPLETE =====");
logMessage("info", "Successfully recorded message with ID:", message_id);
// 5. Return the message_id
return message_id;
} catch (error) {
logMessage("error", "===== RECORD MESSAGE - ERROR =====");
logMessage("error", "Failed to record message:", { error: error.message });
logMessage("error", "Error stack:", { stack: error.stack });
throw new Error("Failed to record message: " + error.message);
}
}
/**
* Detects if a new message represents a topic shift in the conversation.
*
* @param {string} newMessageContent - The content of the new message
* @param {string} conversationId - The conversation ID
* @returns {Promise<boolean>} True if a topic shift is detected, false otherwise
*/
export async function detectTopicShift(newMessageContent, conversationId) {
// Fetch recent conversation history (last 5-10 messages)
const history = await getConversationHistory(conversationId, 10);
// Build the new message object (assume role is 'user' for this context)
const newMessage = { content: newMessageContent, role: "user" };
// Delegate to ConversationSegmenter
const isShift = await ConversationSegmenter.detectTopicShift(
newMessage,
history
);
return isShift;
}
/**
* Gets all topics for a conversation, either as a flat list or hierarchical structure.
*
* @param {string} conversationId - The conversation ID
* @param {boolean} [hierarchical=false] - Whether to return a hierarchical structure
* @returns {Promise<Topic[] | {rootTopics: Topic[], topicMap: Record<string, Topic>}>}
*/
export async function getConversationTopics(
conversationId,
hierarchical = false
) {
try {
if (hierarchical) {
// Use ConversationSegmenter to build hierarchy
return await ConversationSegmenter.buildTopicHierarchy(conversationId);
}
// Flat list: query conversation_topics table
const query = `
SELECT * FROM conversation_topics
WHERE conversation_id = ?
ORDER BY start_timestamp ASC
`;
const result = await executeQuery(query, [conversationId]);
// Check if result has rows property and it's not empty
if (!result || !result.rows || result.rows.length === 0) {
logMessage("info", `No topics found for conversation: ${conversationId}`);
return [];
}
// Create new objects with parsed JSON fields instead of modifying the originals
return result.rows.map((topic) => {
const newTopic = { ...topic }; // Create a shallow copy
try {
// Parse JSON strings into new properties
newTopic.primary_entities = topic.primary_entities
? JSON.parse(topic.primary_entities)
: [];
newTopic.keywords = topic.keywords ? JSON.parse(topic.keywords) : [];
} catch (err) {
logMessage(
"warn",
`Error parsing JSON fields in topic: ${err.message}`
);
newTopic.primary_entities = [];
newTopic.keywords = [];
}
return newTopic;
});
} catch (error) {
logMessage("warn", `Failed to retrieve conversation topics`, {
error: error.message,
conversationId,
});
// Return empty array on error
return [];
}
}
/**
* Returns messages most relevant to a query, using keyword overlap and optional topic/purpose boosting.
*
* @param {string} query - The search query
* @param {string} conversationId - The conversation ID
* @param {Object} [options] - Optional filters and limit
* @param {boolean} [options.purposeFilter] - Boost messages in active purpose
* @param {boolean} [options.topicFilter] - Boost messages in active topic
* @param {number} [options.limit] - Max number of results to return
* @returns {Promise<Message[]>} Array of relevant messages
*/
export async function getRelevantConversationContext(
query,
conversationId,
options = {}
) {
// 1. Fetch all messages for the conversation (limit to 200 for performance)
const allMessages = await getConversationHistory(conversationId, 200);
if (!allMessages || allMessages.length === 0) return [];
// 2. Tokenize query and extract keywords
const queryTokens = TextTokenizerLogic.tokenize(query);
const queryKeywords = new Set(
TextTokenizerLogic.extractKeywords(queryTokens, 10)
);
// 3. Get active topic and purpose if needed
let activeTopicId = null;
let activePurpose = null;
if (options.topicFilter) {
const activeTopic =
await ConversationSegmenter.getActiveTopicForConversation(conversationId);
activeTopicId = activeTopic ? activeTopic.topic_id : null;
}
if (options.purposeFilter) {
activePurpose = await ConversationPurposeDetector.getActivePurpose(
conversationId
);
}
// 4. Score each message
const scoredMessages = allMessages.map((msg) => {
// Tokenize and extract keywords from message content
const msgTokens = TextTokenizerLogic.tokenize(msg.content || "");
const msgKeywords = new Set(
TextTokenizerLogic.extractKeywords(msgTokens, 10)
);
// Calculate Jaccard index (overlap / union)
const intersection = new Set(
[...queryKeywords].filter((x) => msgKeywords.has(x))
);
const union = new Set([...queryKeywords, ...msgKeywords]);
let relevance = union.size > 0 ? intersection.size / union.size : 0;
// Boost for topic
if (
options.topicFilter &&
activeTopicId &&
msg.topic_segment_id === activeTopicId
) {
relevance += 0.2;
}
// Boost for purpose (if message timestamp falls within active purpose window)
if (
options.purposeFilter &&
activePurpose &&
activePurpose.start_timestamp
) {
const msgTime = msg.timestamp || msg.created_at;
if (
msgTime >= activePurpose.start_timestamp &&
(!activePurpose.end_timestamp || msgTime <= activePurpose.end_timestamp)
) {
relevance += 0.15;
}
}
return { ...msg, relevance };
});
// 5. Sort by relevance DESC
scoredMessages.sort((a, b) => b.relevance - a.relevance);
// 6. Apply limit
const limit = options.limit || 10;
const topMessages = scoredMessages.slice(0, limit);
// 7. Remove temporary relevance field before returning
return topMessages.map(({ relevance, ...msg }) => msg);
}
/**
* Infers the type of development task being discussed in a conversation.
*
* @param {string} conversationId - The conversation ID
* @returns {Promise<string|null>} The inferred task type or null if not confident
*/
export async function getTaskTypeFromConversation(conversationId) {
// 1. Classify the conversation
const classification = await classifyConversation(conversationId);
if (!classification || !classification.purpose) return null;
const { purpose, confidence } = classification;
// 2. Define mapping from purpose to task type
const purposeToTaskType = {
debugging: "bug_fixing",
feature_planning: "new_feature_development",
code_review: "code_review",
learning: "research",
code_generation: "implementation",
optimization: "performance_optimization",
refactoring: "refactoring",
general_query: "research",
documentation: "documentation",
testing: "testing",
};
// 3. Heuristic: if confidence is low or purpose is too generic, return null or default
if (confidence < 0.55 || purpose === "general_query") {
return null;
}
// 4. Map purpose to task type
const taskType = purposeToTaskType[purpose] || "general_development_task";
return taskType;
}
/**
* Provides purpose-specific summaries of different conversation segments.
*
* @param {string} conversationId - The conversation ID
* @returns {Promise<{purpose: string, summary: string}[]>} Array of summaries by purpose
*/
export async function getConversationSummaryByPurpose(conversationId) {
// 1. Get purpose history
const purposeHistory = await ConversationPurposeDetector.getPurposeHistory(
conversationId
);
if (!purposeHistory || purposeHistory.length === 0) return [];
const summaries = [];
for (const segment of purposeHistory) {
// 2. Fetch all messages in this purpose segment
const query = `
SELECT content FROM conversation_history
WHERE conversation_id = ?
AND timestamp >= ?
${segment.end_timestamp ? "AND timestamp <= ?" : ""}
ORDER BY timestamp ASC
`;
const params = [conversationId, segment.start_timestamp];
if (segment.end_timestamp) params.push(segment.end_timestamp);
const messages = await executeQuery(query, params);
if (!messages || messages.length === 0) continue;
// 3. Concatenate message contents
const concatenated = messages.map((m) => m.content).join(" ");
// 4. Summarize using ContextCompressorLogic
const summary = await ContextCompressorLogic.summarizeText(concatenated, {
targetLength: 150,
preserveKeyPoints: true,
});
summaries.push({
purpose: segment.purpose_type,
summary,
});
}
return summaries;
}
/**
* Generates an overall summary for an entire conversation.
*
* @param {string} conversationId - The conversation ID
* @returns {Promise<string>} The generated summary
*/
export async function summarizeConversation(conversationId) {
// 1. Fetch all messages for the conversation, ordered by timestamp ASC
const query = `
SELECT role, content FROM conversation_history
WHERE conversation_id = ?
ORDER BY timestamp ASC
`;
const messages = await executeQuery(query, [conversationId]);
// Check if results has a rows property and it's an array
if (
!messages ||
!messages.rows ||
!Array.isArray(messages.rows) ||
messages.rows.length === 0
) {
logMessage(
"warn",
`No valid messages found for conversation ${conversationId}`
);
return "";
}
// 2. Concatenate messages as 'role: content' lines
const concatenated = messages.rows
.map((m) => `${m.role}: ${m.content}`)
.join("\n");
// 3. Summarize using ContextCompressorLogic
const summary = await ContextCompressorLogic.summarizeText(concatenated, {
targetLength: 250,
preserveKeyPoints: true,
});
// 4. Return the summary string
return summary;
}
/**
* Initializes a new conversation in the system
*
* @param {string} conversationId - The conversation ID
* @param {string} initialQuery - The initial query that started the conversation
* @returns {Promise<void>}
*/
export async function initializeConversation(conversationId, initialQuery) {
try {
const timestamp = new Date().toISOString();
let userMessageId = null; // Define outside the if block to make it available in the scope
// First, record the user's initial query as a 'user' message
if (initialQuery && initialQuery.trim()) {
userMessageId = uuidv4();
const userQuery = `
INSERT INTO conversation_history (
message_id,
conversation_id,
role,
content,
timestamp,
related_context_entity_ids,
summary,
user_intent
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
await executeQuery(userQuery, [
userMessageId,
conversationId,
"user", // Store as user role to properly track the user's input
initialQuery,
timestamp,
JSON.stringify([]),
"Initial user query",
"start_conversation",
]);
logMessage("info", `User query recorded with ID: ${userMessageId}`);
}
// Then create the system message to record conversation initialization
const systemMessageId = uuidv4();
const systemQuery = `
INSERT INTO conversation_history (
message_id,
conversation_id,
role,
content,
timestamp,
related_context_entity_ids,
summary,
user_intent
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
await executeQuery(systemQuery, [
systemMessageId,
conversationId,
"system",
initialQuery ? "Conversation started with query" : "Conversation started",
timestamp,
JSON.stringify([]),
"Conversation initialization",
"start_conversation",
]);
// Initialize conversation purpose based on initial query
if (initialQuery) {
await ConversationPurposeDetector.detectInitialPurpose(
conversationId,
initialQuery
);
}
// Create initial topic segment using the user message ID if available
const messageIdForSegment = userMessageId || systemMessageId;
await ConversationSegmenter.createNewTopicSegment(
conversationId,
messageIdForSegment,
{
name: "Initial conversation",
description: initialQuery || "Conversation start",
primaryEntities: [],
keywords: [],
}
);
logMessage("info", `Conversation initialized with ID: ${conversationId}`);
} catch (error) {
logMessage("error", "Error initializing conversation:", {
error: error.message,
});
throw new Error("Failed to initialize conversation: " + error.message);
}
}
/**
* Gets the conversation history for a specific conversation ID
*
* @param {string} conversationId - The conversation ID
* @param {number} [limit=50] - Maximum number of messages to return
* @param {number} [offset=0] - Offset for pagination
* @returns {Promise<Array>} Array of message objects
*/
export async function getConversationHistory(
conversationId,
limit = 50,
offset = 0
) {
try {
if (!conversationId) {
throw new Error("Conversation ID is required");
}
const query = `
SELECT
message_id,
conversation_id,
role,
content,
timestamp,
related_context_entity_ids,
summary,
user_intent,
topic_segment_id,
semantic_markers,
sentiment_indicators
FROM
conversation_history
WHERE
conversation_id = ?
ORDER BY
timestamp ASC
LIMIT ? OFFSET ?
`;
const results = await executeQuery(query, [conversationId, limit, offset]);
// Check if results has a rows property and it's an array
if (!results || !results.rows || !Array.isArray(results.rows)) {
logMessage(
"warn",
"No valid rows returned from conversation history query"
);
return [];
}
// Parse JSON fields
return results.rows.map((message) => {
try {
// Map database column names to camelCase property names for API consistency
const mappedMessage = {
messageId: message.message_id,
conversationId: message.conversation_id,
role: message.role,
content: message.content,
timestamp: message.timestamp,
relatedContextEntityIds: [],
summary: message.summary,
userIntent: message.user_intent,
topicSegmentId: message.topic_segment_id,
semanticMarkers: [],
sentimentIndicators: {},
};
if (message.related_context_entity_ids) {
mappedMessage.relatedContextEntityIds = JSON.parse(
message.related_context_entity_ids
);
}
if (message.semantic_markers) {
mappedMessage.semanticMarkers = JSON.parse(message.semantic_markers);
}
if (message.sentiment_indicators) {
mappedMessage.sentimentIndicators = JSON.parse(
message.sentiment_indicators
);
}
return mappedMessage;
} catch (err) {
logMessage(
"error",
"Error parsing JSON fields in conversation message:"
);
logMessage("error", err);
return {
messageId: message.message_id,
conversationId: message.conversation_id,
role: message.role,
content: message.content,
timestamp: message.timestamp,
relatedContextEntityIds: [],
summary: message.summary,
userIntent: message.user_intent,
topicSegmentId: message.topic_segment_id,
semanticMarkers: [],
sentimentIndicators: {},
};
}
});
} catch (error) {
logMessage(
"error",
`Error getting conversation history for ${conversationId}:`
);
logMessage("error", error);
return [];
}
}
/**
* Gets the current purpose of a conversation
*
* @param {string} conversationId - The ID of the conversation
* @returns {Promise<{purposeType: string, confidence: number, startTimestamp: string}>} The conversation purpose information
*/
export async function getConversationPurpose(conversationId) {
try {
if (!conversationId) {
throw new Error("Conversation ID is required");
}
// Use the ConversationPurposeDetector to get the active purpose
const activePurpose = await ConversationPurposeDetector.getActivePurpose(
conversationId
);
if (!activePurpose) {
// Default response if no purpose is detected
return {
purposeType: "general_query",
confidence: 0.5,
startTimestamp: new Date().toISOString(),
};
}
return activePurpose;
} catch (error) {
logMessage(
"error",
`Error getting conversation purpose for ${conversationId}:`
);
logMessage("error", error);
// Default response in case of error
return {
purposeType: "general_query",
confidence: 0.5,
startTimestamp: new Date().toISOString(),
};
}
}
/**
* Gets the most recent messages for a conversation
*
* @param {string} conversationId - The conversation ID
* @param {number} [count=5] - Number of most recent messages to return
* @returns {Promise<Array>} Array of the most recent message objects
*/
export async function getRecentMessages(conversationId, count = 5) {
try {
if (!conversationId) {
throw new Error("Conversation ID is required");
}
const query = `
SELECT
message_id,
conversation_id,
role,
content,
timestamp,
related_context_entity_ids,
summary,
user_intent,
topic_segment_id,
semantic_markers,
sentiment_indicators
FROM
conversation_history
WHERE
conversation_id = ?
ORDER BY
timestamp DESC
LIMIT ?
`;
const results = await executeQuery(query, [conversationId, count]);
// Check if results has a rows property and it's an array
if (!results || !results.rows || !Array.isArray(results.rows)) {
logMessage("warn", "No valid rows returned from recent messages query");
return [];
}
// Parse JSON fields using the same mapping as getConversationHistory
return results.rows.map((message) => {
try {
// Map database column names to camelCase property names for API consistency
const mappedMessage = {
messageId: message.message_id,
conversationId: message.conversation_id,
role: message.role,
content: message.content,
timestamp: message.timestamp,
relatedContextEntityIds: [],
summary: message.summary,
userIntent: message.user_intent,
topicSegmentId: message.topic_segment_id,
semanticMarkers: [],
sentimentIndicators: {},
};
if (message.related_context_entity_ids) {
mappedMessage.relatedContextEntityIds = JSON.parse(
message.related_context_entity_ids
);
}
if (message.semantic_markers) {
mappedMessage.semanticMarkers = JSON.parse(message.semantic_markers);
}
if (message.sentiment_indicators) {
mappedMessage.sentimentIndicators = JSON.parse(
message.sentiment_indicators
);
}
return mappedMessage;
} catch (err) {
logMessage(
"error",
"Error parsing JSON fields in conversation message:"
);
logMessage("error", err);
return {
messageId: message.message_id,
conversationId: message.conversation_id,
role: message.role,
content: message.content,
timestamp: message.timestamp,
relatedContextEntityIds: [],
summary: message.summary,
userIntent: message.user_intent,
topicSegmentId: message.topic_segment_id,
semanticMarkers: [],
sentimentIndicators: {},
};
}
});
} catch (error) {
logMessage("error", `Error getting recent messages for ${conversationId}:`);
logMessage("error", error);
return [];
}
}