devcontext
Version:
DevContext is a cutting-edge Model Context Protocol (MCP) server designed to provide developers with continuous, project-centric context awareness.
825 lines (728 loc) • 25.3 kB
JavaScript
/**
* IntentPredictorLogic.js
*
* Provides functions for predicting user intent from queries and conversation history.
*/
import * as TextTokenizerLogic from "./TextTokenizerLogic.js";
import { executeQuery } from "../db.js";
import { v4 as uuidv4 } from "uuid";
import * as TimelineManagerLogic from "./TimelineManagerLogic.js";
/**
* @typedef {Object} Message
* @property {string} content - The content of the message
* @property {string} role - The role of the message sender (user or assistant)
*/
/**
* @typedef {Object} TimelineEvent
* @property {string} event_id - Unique identifier for the event
* @property {string} event_type - Type of event
* @property {number} timestamp - Timestamp when the event occurred
* @property {Object} data - Event data parsed from JSON
* @property {string[]} associated_entity_ids - IDs of entities associated with this event
* @property {string|null} conversation_id - Optional conversation ID this event belongs to
* @property {string} created_at - Timestamp when the event was created in the database
*/
/**
* @typedef {Object} CodeChangeInfo
* @property {string} path - Path to the file being edited
* @property {string} [content] - Optional content of the file
*/
/**
* @typedef {Object} FocusArea
* @property {string} focus_id - Unique identifier for the focus area
* @property {string} focus_type - Type of focus area ('file', 'directory', 'task_type')
* @property {string} identifier - Primary identifier for the focus area (e.g., file path)
* @property {string} description - Human-readable description of the focus area
* @property {string} related_entity_ids - JSON string of related entity IDs
* @property {string} keywords - JSON string of keywords related to this focus area
* @property {number} last_activated_at - Timestamp when this focus area was last active
* @property {boolean} is_active - Whether this focus area is currently active
*/
/**
* @typedef {Object} IntentInfo
* @property {string} intent - The inferred intent type
* @property {number} [confidence] - Confidence score for the intent (0-1)
* @property {string[]} [keywords] - Array of extracted keywords
* @property {FocusArea} [focusArea] - The currently active focus area, if available
*/
/**
* @typedef {Object} IntentUpdateResult
* @property {IntentInfo} [newIntent] - The newly inferred intent, if available
* @property {boolean} [focusUpdated] - Whether the focus area was updated
* @property {FocusArea} [currentFocus] - The current focus area after update
*/
/**
* Infers the user's intent from a query and conversation history
*
* @param {string} query - The user's query
* @param {Message[]} [conversationHistory=[]] - The recent conversation history
* @returns {Object} Object containing intent and keywords
* @returns {string} .intent - The inferred intent
* @returns {string[]} .keywords - Array of extracted keywords
*/
export function inferIntentFromQuery(query, conversationHistory = []) {
// Define possible intents
const intents = {
GENERAL_QUERY: "general_query",
CODE_SEARCH: "code_search",
EXPLANATION_REQUEST: "explanation_request",
DEBUGGING_ASSIST: "debugging_assist",
REFACTORING_SUGGESTION: "refactoring_suggestion",
IMPLEMENTATION_REQUEST: "implementation_request",
DOCUMENTATION_REQUEST: "documentation_request",
};
// Initialize scores for each intent
const intentScores = {
[intents.GENERAL_QUERY]: 0.1, // Base score
[intents.CODE_SEARCH]: 0,
[intents.EXPLANATION_REQUEST]: 0,
[intents.DEBUGGING_ASSIST]: 0,
[intents.REFACTORING_SUGGESTION]: 0,
[intents.IMPLEMENTATION_REQUEST]: 0,
[intents.DOCUMENTATION_REQUEST]: 0,
};
// Normalize the query
const normalizedQuery = query.toLowerCase();
// Extract keywords using TextTokenizerLogic
const tokens = TextTokenizerLogic.tokenize(query);
const keywords = TextTokenizerLogic.extractKeywords(tokens);
// Check for question marks (indicates question/explanation request)
if (normalizedQuery.includes("?")) {
intentScores[intents.EXPLANATION_REQUEST] += 0.3;
}
// Check for code snippets (code blocks, function names, variable declarations)
const codePatterns = [
/```[\s\S]*?```/, // Code blocks
/function\s+\w+\s*\(.*?\)/, // Function declarations
/const|let|var\s+\w+\s*=/, // Variable declarations
/class\s+\w+/, // Class declarations
/import\s+.*?from/, // Import statements
];
for (const pattern of codePatterns) {
if (pattern.test(query)) {
intentScores[intents.CODE_SEARCH] += 0.2;
intentScores[intents.DEBUGGING_ASSIST] += 0.2;
break;
}
}
// Check for specific keywords
const keywordPatterns = [
// Search related
{
patterns: ["find", "search", "where is", "locate", "look for"],
intent: intents.CODE_SEARCH,
score: 0.6,
},
// Explanation related
{
patterns: [
"explain",
"how does",
"what is",
"why",
"how to",
"tell me about",
],
intent: intents.EXPLANATION_REQUEST,
score: 0.6,
},
// Debugging related
{
patterns: [
"error",
"bug",
"issue",
"problem",
"fix",
"debug",
"not working",
"exception",
"fail",
],
intent: intents.DEBUGGING_ASSIST,
score: 0.7,
},
// Refactoring related
{
patterns: [
"refactor",
"improve",
"optimize",
"clean",
"better way",
"restructure",
"revise",
],
intent: intents.REFACTORING_SUGGESTION,
score: 0.65,
},
// Implementation related
{
patterns: [
"implement",
"create",
"make",
"build",
"develop",
"code",
"add",
"new feature",
],
intent: intents.IMPLEMENTATION_REQUEST,
score: 0.6,
},
// Documentation related
{
patterns: [
"document",
"comment",
"describe",
"explain code",
"documentation",
],
intent: intents.DOCUMENTATION_REQUEST,
score: 0.55,
},
];
for (const { patterns, intent, score } of keywordPatterns) {
for (const pattern of patterns) {
if (normalizedQuery.includes(pattern)) {
intentScores[intent] += score;
break; // Only add the score once per pattern group
}
}
}
// Analyze conversation history for context
if (conversationHistory && conversationHistory.length > 0) {
// Get last few messages, focusing on user messages
const recentMessages = conversationHistory
.slice(-3) // Last 3 messages
.filter((msg) => msg.content);
for (const message of recentMessages) {
const normalizedContent = message.content.toLowerCase();
// If previous messages contained errors or debug terms, boost debugging intent
if (
/error|bug|issue|problem|fix|debug|not working|exception|fail/.test(
normalizedContent
)
) {
intentScores[intents.DEBUGGING_ASSIST] += 0.2;
}
// If previous messages discussed code structure, boost refactoring intent
if (
/refactor|improve|optimize|clean|better|restructure|architecture/.test(
normalizedContent
)
) {
intentScores[intents.REFACTORING_SUGGESTION] += 0.2;
}
// If previous messages were about explaining, boost explanation intent
if (
/explain|how does|what is|why|how to|understand/.test(normalizedContent)
) {
intentScores[intents.EXPLANATION_REQUEST] += 0.15;
}
}
}
// Determine the winning intent
let maxScore = 0;
let inferredIntent = intents.GENERAL_QUERY; // Default
for (const [intent, score] of Object.entries(intentScores)) {
if (score > maxScore) {
maxScore = score;
inferredIntent = intent;
}
}
return {
intent: inferredIntent,
keywords,
};
}
/**
* Predicts the current focus area based on recent activity and code edits
*
* @param {TimelineEvent[]} recentActivity - Recent events from the timeline
* @param {CodeChangeInfo[]} currentCodeEdits - Information about currently edited files
* @returns {Promise<FocusArea|null>} The predicted focus area or null if no clear focus
*/
export async function predictFocusArea(
recentActivity = [],
currentCodeEdits = []
) {
try {
// Track file/path frequencies to determine most common focus areas
const pathFrequency = new Map();
const entityFrequency = new Map();
const activityTypes = new Map();
let keywordsSet = new Set();
// Process recent activity from timeline events
for (const event of recentActivity) {
// Count event types
activityTypes.set(
event.event_type,
(activityTypes.get(event.event_type) || 0) + 1
);
// Track file paths from event data
if (event.data && event.data.path) {
const path = event.data.path;
pathFrequency.set(path, (pathFrequency.get(path) || 0) + 1);
// Add depth to different path segments (directories, etc)
const segments = path.split("/");
for (let i = 1; i < segments.length; i++) {
const dirPath = segments.slice(0, i).join("/");
if (dirPath) {
pathFrequency.set(dirPath, (pathFrequency.get(dirPath) || 0) + 0.3);
}
}
}
// Track related entities
if (
event.associated_entity_ids &&
event.associated_entity_ids.length > 0
) {
for (const entityId of event.associated_entity_ids) {
entityFrequency.set(
entityId,
(entityFrequency.get(entityId) || 0) + 1
);
}
}
// Extract keywords from event data
if (event.data && typeof event.data === "object") {
// Extract keywords from any descriptive fields
const textFields = [
event.data.description,
event.data.message,
event.data.content,
event.data.query,
].filter(Boolean);
for (const text of textFields) {
if (text && typeof text === "string") {
const tokens = TextTokenizerLogic.tokenize(text);
const extractedKeywords =
TextTokenizerLogic.extractKeywords(tokens);
extractedKeywords.forEach((keyword) => keywordsSet.add(keyword));
}
}
}
}
// Process current code edits (these should get more weight as they represent current focus)
for (const edit of currentCodeEdits) {
const path = edit.path;
// Give higher weight to current edits
pathFrequency.set(path, (pathFrequency.get(path) || 0) + 3);
// Add depth to different path segments (directories, etc)
const segments = path.split("/");
for (let i = 1; i < segments.length; i++) {
const dirPath = segments.slice(0, i).join("/");
if (dirPath) {
pathFrequency.set(dirPath, (pathFrequency.get(dirPath) || 0) + 0.5);
}
}
// Extract keywords from content if available
if (edit.content) {
const tokens = TextTokenizerLogic.tokenize(edit.content);
const extractedKeywords = TextTokenizerLogic.extractKeywords(tokens);
extractedKeywords.forEach((keyword) => keywordsSet.add(keyword));
}
}
// Find the most frequent paths and entities
let primaryFocusPath = "";
let maxFrequency = 0;
let focusType = "file";
for (const [path, frequency] of pathFrequency.entries()) {
if (frequency > maxFrequency) {
maxFrequency = frequency;
primaryFocusPath = path;
// Determine if it's a file or directory
focusType =
path.includes(".") && !path.endsWith("/") ? "file" : "directory";
}
}
// If we couldn't determine a clear focus from paths, try to determine from activity types
if (!primaryFocusPath && activityTypes.size > 0) {
let primaryActivityType = "";
maxFrequency = 0;
for (const [type, frequency] of activityTypes.entries()) {
if (frequency > maxFrequency) {
maxFrequency = frequency;
primaryActivityType = type;
}
}
if (primaryActivityType) {
primaryFocusPath = `activity:${primaryActivityType}`;
focusType = "task_type";
}
}
// If we still have no clear focus, return null
if (!primaryFocusPath) {
return null;
}
// Create a human-readable description
let description = "";
if (focusType === "file") {
description = `Working on file ${primaryFocusPath}`;
} else if (focusType === "directory") {
description = `Working in directory ${primaryFocusPath}`;
} else {
description = `${primaryFocusPath.replace("activity:", "")} activity`;
}
// Collect related entity IDs (most frequent ones)
const relatedEntityIds = Array.from(entityFrequency.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([entityId]) => entityId);
// Collect keywords (convert Set to Array)
const keywords = Array.from(keywordsSet).slice(0, 20);
// Create the focus area object
const focusArea = {
focus_id: uuidv4(),
focus_type: focusType,
identifier: primaryFocusPath,
description,
related_entity_ids: JSON.stringify(relatedEntityIds),
keywords: JSON.stringify(keywords),
last_activated_at: Date.now(),
is_active: true,
};
// Call updateFocusAreaInDb to persist the focus area
// Note: This function will be implemented in another task
try {
await updateFocusAreaInDb(focusArea);
} catch (error) {
// Log the error but don't fail - we still want to return the computed focus area
console.error("Error updating focus area in database:", error);
}
return focusArea;
} catch (error) {
console.error("Error predicting focus area:", error);
return null;
}
}
/**
* Updates or creates a focus area in the database
*
* @param {FocusArea} focus - The focus area to update or create
* @returns {Promise<void>}
*/
export async function updateFocusAreaInDb(focus) {
try {
// Ensure that related_entity_ids and keywords are JSON strings
const relatedEntityIds =
typeof focus.related_entity_ids === "string"
? focus.related_entity_ids
: JSON.stringify(focus.related_entity_ids || []);
const keywords =
typeof focus.keywords === "string"
? focus.keywords
: JSON.stringify(focus.keywords || []);
// Ensure last_activated_at is set to current time if not provided
const lastActivated = focus.last_activated_at || Date.now();
// Begin transaction - execute a series of queries that should complete together
await executeQuery("BEGIN TRANSACTION");
try {
// Step 1: Set all existing focus areas to inactive
await executeQuery(
"UPDATE focus_areas SET is_active = FALSE WHERE is_active = TRUE"
);
// Step 2: Check if the focus area already exists
const existingFocus = await executeQuery(
"SELECT focus_id FROM focus_areas WHERE identifier = ?",
[focus.identifier]
);
if (existingFocus && existingFocus.length > 0) {
// Update existing focus area
await executeQuery(
`UPDATE focus_areas SET
focus_type = ?,
description = ?,
related_entity_ids = ?,
keywords = ?,
last_activated_at = ?,
is_active = TRUE
WHERE focus_id = ?`,
[
focus.focus_type,
focus.description,
relatedEntityIds,
keywords,
lastActivated,
existingFocus[0].focus_id,
]
);
} else {
// Insert new focus area
await executeQuery(
`INSERT INTO focus_areas (
focus_id,
focus_type,
identifier,
description,
related_entity_ids,
keywords,
last_activated_at,
is_active
) VALUES (?, ?, ?, ?, ?, ?, ?, TRUE)`,
[
focus.focus_id,
focus.focus_type,
focus.identifier,
focus.description,
relatedEntityIds,
keywords,
lastActivated,
]
);
}
// Commit the transaction
await executeQuery("COMMIT");
} catch (error) {
// If any query fails, roll back the transaction
await executeQuery("ROLLBACK");
throw error;
}
} catch (error) {
console.error("Error updating focus area in database:", error);
throw error;
}
}
/**
* Retrieves and analyzes the current intent for a conversation
*
* @param {string} conversationId - The ID of the conversation to analyze
* @returns {Promise<IntentInfo|null>} The intent information or null if no clear intent
*/
export async function getIntent(conversationId) {
try {
// 1. Retrieve the most recent messages for the given conversationId
const recentMessages = await executeQuery(
`SELECT content, role, timestamp
FROM conversation_history
WHERE conversation_id = ?
ORDER BY timestamp DESC
LIMIT 5`,
[conversationId]
);
if (!recentMessages || recentMessages.length === 0) {
return null; // No messages found for this conversation
}
// Convert to the Message format expected by inferIntentFromQuery
const messages = recentMessages.map((msg) => ({
content: msg.content,
role: msg.role,
}));
// Get the most recent user message
const lastUserMessage = messages.find((msg) => msg.role === "user");
if (!lastUserMessage) {
return null; // No user messages found
}
// 2. Analyze the messages using inferIntentFromQuery
const { intent, keywords } = inferIntentFromQuery(
lastUserMessage.content,
messages
);
// 3. Get the currently active focus area
const activeFocusAreas = await executeQuery(
"SELECT * FROM focus_areas WHERE is_active = TRUE LIMIT 1"
);
let focusArea = null;
if (activeFocusAreas && activeFocusAreas.length > 0) {
const rawFocusArea = activeFocusAreas[0];
// Parse JSON fields
focusArea = {
...rawFocusArea,
related_entity_ids: JSON.parse(rawFocusArea.related_entity_ids || "[]"),
keywords: JSON.parse(rawFocusArea.keywords || "[]"),
};
}
// 4. Calculate a confidence score based on the clarity of intent
// This is simplified - a real implementation might use more sophisticated scoring
let confidence = 0.5; // Default medium confidence
// Increase confidence if we have both clear intent and matching focus area
if (intent !== "general_query" && focusArea) {
confidence = 0.7;
// Check if any keywords match the focus area keywords
if (focusArea.keywords && keywords) {
const matchingKeywords = keywords.filter((k) =>
focusArea.keywords.includes(k)
);
if (matchingKeywords.length > 0) {
confidence += Math.min(0.3, matchingKeywords.length * 0.05);
}
}
}
// 5. Combine the information into an IntentInfo object
const intentInfo = {
intent,
confidence,
keywords,
focusArea,
};
return intentInfo;
} catch (error) {
console.error("Error getting intent for conversation:", error);
return null;
}
}
/**
* Updates the intent and focus area based on new activity signals
*
* @param {Object} params - Parameters containing activity signals
* @param {string} params.conversationId - ID of the conversation to update
* @param {string} [params.newMessage] - New message content, if any
* @param {boolean} [params.isUser=false] - Whether the new message is from the user
* @param {string} [params.activeFile] - Currently active file path, if any
* @param {CodeChangeInfo[]} [params.codeChanges] - Information about code changes
* @returns {Promise<IntentUpdateResult>} Result indicating intent and focus updates
*/
export async function updateIntent(params) {
try {
const {
conversationId,
newMessage,
isUser = false,
activeFile,
codeChanges = [],
} = params;
let newIntent = null;
let focusUpdated = false;
let currentFocus = null;
// 1. If new message is present and from user, determine textual intent
if (newMessage && isUser) {
// Get recent conversation history
const recentMessages = await executeQuery(
`SELECT content, role, timestamp
FROM conversation_history
WHERE conversation_id = ?
ORDER BY timestamp DESC
LIMIT 5`,
[conversationId]
);
// Convert to Message format and add the new message
const messages = recentMessages.map((msg) => ({
content: msg.content,
role: msg.role,
}));
// Add the new message to the history
messages.unshift({
content: newMessage,
role: "user",
});
// Infer intent from the new message
const { intent, keywords } = inferIntentFromQuery(newMessage, messages);
// Get the current focus area
const activeFocusAreas = await executeQuery(
"SELECT * FROM focus_areas WHERE is_active = TRUE LIMIT 1"
);
let focusArea = null;
if (activeFocusAreas && activeFocusAreas.length > 0) {
const rawFocusArea = activeFocusAreas[0];
// Parse JSON fields
focusArea = {
...rawFocusArea,
related_entity_ids: JSON.parse(
rawFocusArea.related_entity_ids || "[]"
),
keywords: JSON.parse(rawFocusArea.keywords || "[]"),
};
}
// Calculate confidence
let confidence = 0.5; // Default medium confidence
if (intent !== "general_query" && focusArea) {
confidence = 0.7;
// Check if any keywords match the focus area keywords
if (focusArea.keywords && keywords) {
const matchingKeywords = keywords.filter((k) =>
focusArea.keywords.includes(k)
);
if (matchingKeywords.length > 0) {
confidence += Math.min(0.3, matchingKeywords.length * 0.05);
}
}
}
// Create IntentInfo object
newIntent = {
intent,
confidence,
keywords,
focusArea,
};
}
// 2. Determine if project-level focus has shifted based on code activity
// First, gather relevant activity information
const codeActivity = [];
// Add active file as a code activity if provided
if (activeFile) {
codeActivity.push({
path: activeFile,
});
}
// Add code changes
if (codeChanges && codeChanges.length > 0) {
codeActivity.push(...codeChanges);
}
// Get recent timeline events
const recentEvents = await TimelineManagerLogic.getEvents({
limit: 20,
types: ["code_change", "file_open", "cursor_move", "navigation"],
});
// If we have any code activity or recent events, check for focus shift
if (codeActivity.length > 0 || recentEvents.length > 0) {
// Predict focus area based on activity
const newFocusArea = await predictFocusArea(recentEvents, codeActivity);
if (newFocusArea) {
// Focus was updated by predictFocusArea
focusUpdated = true;
currentFocus = newFocusArea;
} else {
// No focus update, get current focus
const activeFocusAreas = await executeQuery(
"SELECT * FROM focus_areas WHERE is_active = TRUE LIMIT 1"
);
if (activeFocusAreas && activeFocusAreas.length > 0) {
const rawFocusArea = activeFocusAreas[0];
// Parse JSON fields
currentFocus = {
...rawFocusArea,
related_entity_ids: JSON.parse(
rawFocusArea.related_entity_ids || "[]"
),
keywords: JSON.parse(rawFocusArea.keywords || "[]"),
};
}
}
} else {
// No code activity, just get current focus
const activeFocusAreas = await executeQuery(
"SELECT * FROM focus_areas WHERE is_active = TRUE LIMIT 1"
);
if (activeFocusAreas && activeFocusAreas.length > 0) {
const rawFocusArea = activeFocusAreas[0];
// Parse JSON fields
currentFocus = {
...rawFocusArea,
related_entity_ids: JSON.parse(
rawFocusArea.related_entity_ids || "[]"
),
keywords: JSON.parse(rawFocusArea.keywords || "[]"),
};
}
}
// If we have a new intent but no focus area in it, add the current focus
if (newIntent && !newIntent.focusArea && currentFocus) {
newIntent.focusArea = currentFocus;
}
// Return the IntentUpdateResult
return {
newIntent,
focusUpdated,
currentFocus,
};
} catch (error) {
console.error("Error updating intent:", error);
// Return minimal information in case of error
return {
focusUpdated: false,
};
}
}