topic-change.js•13.6 kB
/**
* Claude Code Topic Change Hook
* Monitors conversation flow and dynamically loads relevant memories when topics evolve
* Phase 2: Intelligent Context Updates
*/
const fs = require('fs').promises;
const path = require('path');
const https = require('https');
// Import utilities
const { analyzeConversation, detectTopicChanges } = require('../utilities/conversation-analyzer');
const { scoreMemoryRelevance } = require('../utilities/memory-scorer');
const { formatMemoriesForContext } = require('../utilities/context-formatter');
// Global state for conversation tracking
let conversationState = {
previousAnalysis: null,
loadedMemoryHashes: new Set(),
sessionContext: null,
topicChangeCount: 0
};
/**
* Load hook configuration
*/
async function loadConfig() {
try {
const configPath = path.join(__dirname, '../config.json');
const configData = await fs.readFile(configPath, 'utf8');
return JSON.parse(configData);
} catch (error) {
console.warn('[Topic Change Hook] Using default configuration:', error.message);
return {
memoryService: {
endpoint: 'https://10.0.1.30:8443',
apiKey: 'test-key-123',
maxMemoriesPerSession: 8
},
hooks: {
topicChange: {
enabled: true,
timeout: 5000,
priority: 'low',
minSignificanceScore: 0.3,
maxMemoriesPerUpdate: 3
}
}
};
}
}
/**
* Query memory service for topic-specific memories
*/
async function queryMemoryService(endpoint, apiKey, query, options = {}) {
return new Promise((resolve, reject) => {
const {
limit = 5,
excludeHashes = []
} = options;
const url = new URL('/mcp', endpoint);
const postData = JSON.stringify({
jsonrpc: '2.0',
id: Date.now(),
method: 'tools/call',
params: {
name: 'retrieve_memory',
arguments: {
query: query,
limit: limit
}
}
});
const requestOptions = {
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'Content-Length': Buffer.byteLength(postData)
},
rejectUnauthorized: false,
timeout: 5000
};
const req = https.request(requestOptions, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const response = JSON.parse(data);
if (response.error) {
console.error('[Topic Change Hook] Memory service error:', response.error);
resolve([]);
return;
}
// Parse memory results from response
const memories = parseMemoryResults(response.result);
// Filter out already loaded memories
const filteredMemories = memories.filter(memory =>
!excludeHashes.includes(memory.content_hash)
);
console.log(`[Topic Change Hook] Retrieved ${filteredMemories.length} new memories for topic query`);
resolve(filteredMemories);
} catch (parseError) {
console.error('[Topic Change Hook] Failed to parse memory response:', parseError.message);
resolve([]);
}
});
});
req.on('error', (error) => {
console.error('[Topic Change Hook] Memory service request failed:', error.message);
resolve([]);
});
req.on('timeout', () => {
console.error('[Topic Change Hook] Memory service request timed out');
req.destroy();
resolve([]);
});
req.write(postData);
req.end();
});
}
/**
* Parse memory results from MCP response
*/
function parseMemoryResults(result) {
try {
if (result && result.content && result.content[0] && result.content[0].text) {
const text = result.content[0].text;
// Try to extract results array from the response text
const resultsMatch = text.match(/'results':\s*(\[[\s\S]*?\])/);
if (resultsMatch) {
// Use eval carefully on controlled content
const resultsArray = eval(resultsMatch[1]);
return resultsArray || [];
}
}
return [];
} catch (error) {
console.error('[Topic Change Hook] Error parsing memory results:', error.message);
return [];
}
}
/**
* Generate search queries from conversation analysis
*/
function generateTopicQueries(analysis, changes) {
const queries = [];
// Query for new topics
changes.newTopics.forEach(topic => {
queries.push({
query: topic.name,
weight: topic.confidence,
type: 'topic'
});
});
// Query for current intent if changed
if (changes.changedIntents && analysis.intent) {
queries.push({
query: analysis.intent.name,
weight: analysis.intent.confidence,
type: 'intent'
});
}
// Query for high-confidence entities
analysis.entities
.filter(entity => entity.confidence > 0.7)
.slice(0, 2) // Limit to top 2 entities
.forEach(entity => {
queries.push({
query: entity.name,
weight: entity.confidence,
type: 'entity'
});
});
// Sort by weight and return top queries
return queries
.sort((a, b) => b.weight - a.weight)
.slice(0, 3); // Limit to top 3 queries
}
/**
* Format context update message
*/
function formatContextUpdate(memories, analysis, changes) {
if (memories.length === 0) {
return null;
}
let updateMessage = '\n🧠 **Dynamic Memory Context Update**\n\n';
// Explain why context is being updated
if (changes.newTopics.length > 0) {
updateMessage += `**New topics detected:** ${changes.newTopics.map(t => t.name).join(', ')}\n\n`;
}
if (changes.changedIntents) {
updateMessage += `**Conversation focus shifted:** ${analysis.intent.name}\n\n`;
}
// Add relevant memories
updateMessage += '**Additional relevant context:**\n';
memories.slice(0, 3).forEach((memory, index) => {
const content = memory.content.length > 120 ?
memory.content.substring(0, 120) + '...' :
memory.content;
updateMessage += `${index + 1}. ${content}\n`;
if (memory.tags && memory.tags.length > 0) {
updateMessage += ` *Tags: ${memory.tags.slice(0, 3).join(', ')}*\n`;
}
updateMessage += '\n';
});
updateMessage += '---\n';
return updateMessage;
}
/**
* Main topic change detection and processing
* @param {object} context - Conversation context
*/
async function onTopicChange(context) {
console.log('[Topic Change Hook] Analyzing conversation for topic changes...');
try {
const config = await loadConfig();
// Check if topic change hook is enabled
if (!config.hooks?.topicChange?.enabled) {
console.log('[Topic Change Hook] Hook is disabled, skipping');
return;
}
const {
minSignificanceScore = 0.3,
maxMemoriesPerUpdate = 3
} = config.hooks.topicChange;
// Analyze current conversation
const currentAnalysis = analyzeConversation(context.conversationText || '', {
extractTopics: true,
extractEntities: true,
detectIntent: true,
minTopicConfidence: 0.3
});
// Detect topic changes
const changes = detectTopicChanges(conversationState.previousAnalysis, currentAnalysis);
// Only proceed if significant topic change detected
if (!changes.hasTopicShift || changes.significanceScore < minSignificanceScore) {
console.log(`[Topic Change Hook] No significant topic change detected (score: ${changes.significanceScore.toFixed(2)})`);
conversationState.previousAnalysis = currentAnalysis;
return;
}
console.log(`[Topic Change Hook] Significant topic change detected (score: ${changes.significanceScore.toFixed(2)})`);
console.log(`[Topic Change Hook] New topics: ${changes.newTopics.map(t => t.name).join(', ')}`);
// Generate search queries for new topics
const queries = generateTopicQueries(currentAnalysis, changes);
if (queries.length === 0) {
console.log('[Topic Change Hook] No actionable queries generated');
conversationState.previousAnalysis = currentAnalysis;
return;
}
// Query memory service for each topic
const allMemories = [];
for (const queryObj of queries) {
const memories = await queryMemoryService(
config.memoryService.endpoint,
config.memoryService.apiKey,
queryObj.query,
{
limit: 2,
excludeHashes: Array.from(conversationState.loadedMemoryHashes)
}
);
// Add query context to memories
memories.forEach(memory => {
memory.queryContext = queryObj;
});
allMemories.push(...memories);
}
if (allMemories.length === 0) {
console.log('[Topic Change Hook] No new relevant memories found');
conversationState.previousAnalysis = currentAnalysis;
return;
}
// Score memories for relevance
const projectContext = conversationState.sessionContext || { name: 'unknown' };
const scoredMemories = scoreMemoryRelevance(allMemories, projectContext, {
includeConversationContext: true,
conversationAnalysis: currentAnalysis
});
// Select top memories for context update
const selectedMemories = scoredMemories
.filter(memory => memory.relevanceScore > 0.3)
.slice(0, maxMemoriesPerUpdate);
if (selectedMemories.length === 0) {
console.log('[Topic Change Hook] No high-relevance memories found');
conversationState.previousAnalysis = currentAnalysis;
return;
}
// Track loaded memories
selectedMemories.forEach(memory => {
conversationState.loadedMemoryHashes.add(memory.content_hash);
});
// Format context update
const contextUpdate = formatContextUpdate(selectedMemories, currentAnalysis, changes);
if (contextUpdate) {
// In a real implementation, this would inject the context into the conversation
console.log('[Topic Change Hook] Context update generated:');
console.log(contextUpdate);
// For now, we'll simulate the context injection
if (context.onContextUpdate && typeof context.onContextUpdate === 'function') {
context.onContextUpdate(contextUpdate);
}
}
// Update conversation state
conversationState.previousAnalysis = currentAnalysis;
conversationState.topicChangeCount++;
console.log(`[Topic Change Hook] Topic change processing completed (${conversationState.topicChangeCount} changes total)`);
} catch (error) {
console.error('[Topic Change Hook] Error processing topic change:', error.message);
}
}
/**
* Initialize topic change tracking for a new session
* @param {object} sessionContext - Session context information
*/
function initializeTopicTracking(sessionContext) {
console.log('[Topic Change Hook] Initializing topic tracking for new session');
conversationState = {
previousAnalysis: null,
loadedMemoryHashes: new Set(),
sessionContext: sessionContext,
topicChangeCount: 0
};
}
/**
* Reset topic tracking state
*/
function resetTopicTracking() {
console.log('[Topic Change Hook] Resetting topic tracking state');
conversationState = {
previousAnalysis: null,
loadedMemoryHashes: new Set(),
sessionContext: null,
topicChangeCount: 0
};
}
/**
* Get current topic tracking statistics
*/
function getTopicTrackingStats() {
return {
topicChangeCount: conversationState.topicChangeCount,
loadedMemoriesCount: conversationState.loadedMemoryHashes.size,
hasSessionContext: !!conversationState.sessionContext,
lastAnalysis: conversationState.previousAnalysis
};
}
module.exports = {
onTopicChange,
initializeTopicTracking,
resetTopicTracking,
getTopicTrackingStats
};