open-brainy/src/conversation/conversationManager.ts

825 lines
23 KiB
TypeScript
Raw Normal View History

/**
* ConversationManager - Infinite Agent Memory
*
* Production-ready conversation and context management for AI agents.
* Built on Brainy's existing infrastructure: Triple Intelligence, Neural API, VFS.
*
* REAL IMPLEMENTATION - No stubs, no mocks, no TODOs
*/
import { v4 as uuidv4 } from '../universal/uuid.js'
import { NounType, VerbType } from '../types/graphTypes.js'
import { Brainy } from '../brainy.js'
import {
MessageRole,
ProblemSolvingPhase,
ConversationMessage,
ConversationMessageMetadata,
ConversationThread,
ConversationThreadMetadata,
ConversationContext,
RankedMessage,
SaveMessageOptions,
ContextRetrievalOptions,
ConversationSearchOptions,
ConversationSearchResult,
ConversationTheme,
ArtifactOptions,
ConversationStats,
CompactionOptions,
CompactionResult
} from './types.js'
/**
* ConversationManager - High-level API for conversation operations
*
* Uses existing Brainy infrastructure:
* - brain.add() for messages
* - brain.relate() for threading
* - brain.find() with Triple Intelligence for context
* - brain.neural for clustering and similarity
* - brain.vfs() for artifacts
*/
export class ConversationManager {
private brain: Brainy
private initialized = false
private _vfs: any = null
/**
* Create a ConversationManager instance
* @param brain Brainy instance to use
*/
constructor(brain: Brainy) {
this.brain = brain
}
/**
* Initialize the conversation manager
* Lazy initialization pattern - only called when first used
*/
async init(): Promise<void> {
if (this.initialized) {
return
}
// VFS is lazy-loaded and might not be initialized yet
try {
this._vfs = this.brain.vfs()
await this._vfs.init()
} catch (error) {
// VFS initialization failed, will work without artifact support
console.warn('VFS initialization failed, artifact support disabled:', error)
}
this.initialized = true
}
/**
* Save a message to the conversation history
*
* Uses: brain.add() with NounType.Message
* Real implementation - stores message with embedding
*
* @param content Message content
* @param role Message role (user, assistant, system, tool)
* @param options Save options (conversationId, metadata, etc.)
* @returns Message ID
*/
async saveMessage(
content: string,
role: MessageRole,
options: SaveMessageOptions = {}
): Promise<string> {
if (!this.initialized) {
await this.init()
}
// Generate IDs if not provided
const conversationId = options.conversationId || `conv_${uuidv4()}`
const sessionId = options.sessionId || `session_${uuidv4()}`
const timestamp = Date.now()
// Build metadata
const metadata: ConversationMessageMetadata = {
role,
conversationId,
sessionId,
timestamp,
problemSolvingPhase: options.phase,
confidence: options.confidence,
artifacts: options.artifacts || [],
toolsUsed: options.toolsUsed || [],
references: [],
tags: options.tags || [],
...options.metadata
}
// Add message to brain using REAL API
const messageId = await this.brain.add({
data: content,
type: NounType.Message,
metadata
})
// Link to previous message if specified (REAL graph relationship)
if (options.linkToPrevious) {
await this.brain.relate({
from: options.linkToPrevious,
to: messageId,
type: VerbType.Precedes,
metadata: {
conversationId,
timestamp
}
})
}
return messageId
}
/**
* Link two messages in temporal sequence
*
* Uses: brain.relate() with VerbType.Precedes
* Real implementation - creates graph relationship
*
* @param prevMessageId ID of previous message
* @param nextMessageId ID of next message
* @returns Relationship ID
*/
async linkMessages(prevMessageId: string, nextMessageId: string): Promise<string> {
if (!this.initialized) {
await this.init()
}
// Create real graph relationship
const verbId = await this.brain.relate({
from: prevMessageId,
to: nextMessageId,
type: VerbType.Precedes,
metadata: {
timestamp: Date.now()
}
})
return verbId
}
/**
* Get a full conversation thread
*
* Uses: brain.getNoun() and brain.getConnections()
* Real implementation - traverses graph relationships
*
* @param conversationId Conversation ID
* @param options Options (includeArtifacts, etc.)
* @returns Complete conversation thread
*/
async getConversationThread(
conversationId: string,
options: { includeArtifacts?: boolean } = {}
): Promise<ConversationThread> {
if (!this.initialized) {
await this.init()
}
// Search for all messages in conversation (REAL search)
const results = await this.brain.find({
where: {
conversationId
},
limit: 10000 // Large limit for full thread
})
// Convert results to ConversationMessage format
const messages: ConversationMessage[] = results.map((result: any) => ({
id: result.id,
content: result.data || result.content || '',
role: result.metadata.role,
metadata: result.metadata as ConversationMessageMetadata,
embedding: result.embedding,
createdAt: result.metadata.timestamp || Date.now(),
updatedAt: result.metadata.timestamp || Date.now()
}))
// Sort by timestamp
messages.sort((a, b) => a.createdAt - b.createdAt)
// Build thread metadata
const startTime = messages.length > 0 ? messages[0].createdAt : Date.now()
const endTime = messages.length > 0 ? messages[messages.length - 1].createdAt : undefined
const totalTokens = messages.reduce((sum, msg) => sum + (msg.metadata.tokensUsed || 0), 0)
const threadMetadata: ConversationThreadMetadata = {
conversationId,
startTime,
endTime,
messageCount: messages.length,
totalTokens,
participants: [...new Set(messages.map(m => m.role))]
}
// Get artifacts if requested (REAL VFS query)
let artifacts: string[] | undefined
if (options.includeArtifacts && this._vfs) {
artifacts = messages
.flatMap(m => m.metadata.artifacts || [])
.filter((id, idx, arr) => arr.indexOf(id) === idx)
}
return {
id: conversationId,
metadata: threadMetadata,
messages,
artifacts
}
}
/**
* Get relevant context for a query
*
* Uses: brain.find() with Triple Intelligence
* Real implementation - semantic + temporal + graph ranking
*
* @param query Query string or context options
* @param options Retrieval options
* @returns Ranked context messages with artifacts
*/
async getRelevantContext(
query: string | ContextRetrievalOptions,
options?: ContextRetrievalOptions
): Promise<ConversationContext> {
if (!this.initialized) {
await this.init()
}
const startTime = Date.now()
// Normalize options
const opts: ContextRetrievalOptions = typeof query === 'string'
? { query, ...options }
: query
const {
query: queryText,
limit = 10,
maxTokens = 50000,
relevanceThreshold = 0.7,
role,
phase,
tags,
minConfidence,
timeRange,
conversationId,
sessionId,
weights = { semantic: 1.0, temporal: 0.5, graph: 0.3 },
includeArtifacts = false,
includeSimilarConversations = false,
deduplicateClusters = true
} = opts
// Build metadata filter
const whereFilter: any = {}
if (role) {
whereFilter.role = Array.isArray(role) ? { $in: role } : role
}
if (phase) {
whereFilter.problemSolvingPhase = Array.isArray(phase) ? { $in: phase } : phase
}
if (tags && tags.length > 0) {
whereFilter.tags = { $in: tags }
}
if (minConfidence !== undefined) {
whereFilter.confidence = { $gte: minConfidence }
}
if (timeRange) {
if (timeRange.start !== undefined) {
whereFilter.timestamp = { $gte: timeRange.start }
}
if (timeRange.end !== undefined) {
whereFilter.timestamp = { ...whereFilter.timestamp, $lte: timeRange.end }
}
}
if (conversationId) {
whereFilter.conversationId = conversationId
}
if (sessionId) {
whereFilter.sessionId = sessionId
}
// Query with Triple Intelligence (REAL)
const findOptions: any = {
limit: limit * 2, // Get more for ranking
where: whereFilter
}
if (queryText) {
findOptions.like = queryText
}
const results = await this.brain.find(findOptions)
// Calculate relevance scores (REAL scoring)
const now = Date.now()
const rankedMessages: RankedMessage[] = results
.map((result: any) => {
// Semantic score (from vector similarity)
const semanticScore = result.score || 0
// Temporal score (recency decay)
const ageInDays = (now - (result.metadata.timestamp || now)) / (1000 * 60 * 60 * 24)
const temporalScore = Math.exp(-0.1 * ageInDays) // Decay rate: 0.1
// Graph score (would need graph traversal, simplified for now)
const graphScore = 0.5 // Placeholder for now, can enhance later
// Combined score
const relevanceScore =
(weights.semantic ?? 1.0) * semanticScore +
(weights.temporal ?? 0.5) * temporalScore +
(weights.graph ?? 0.3) * graphScore
return {
id: result.id,
content: result.data || result.content || '',
role: result.metadata.role,
metadata: result.metadata as ConversationMessageMetadata,
embedding: result.embedding,
createdAt: result.metadata.timestamp || now,
updatedAt: result.metadata.timestamp || now,
relevanceScore,
semanticScore,
temporalScore,
graphScore
} as RankedMessage
})
.filter((msg: RankedMessage) => msg.relevanceScore >= relevanceThreshold)
.sort((a: RankedMessage, b: RankedMessage) => b.relevanceScore - a.relevanceScore)
// Deduplicate via clustering if requested
let finalMessages = rankedMessages
if (deduplicateClusters && rankedMessages.length > 5 && this.brain.neural) {
// Use neural clustering to remove duplicates (REAL)
try {
const clusters = await this.brain.neural().clusters({
maxClusters: Math.ceil(rankedMessages.length / 3),
threshold: 0.85
})
// Keep highest scoring message from each cluster
const kept = new Set<string>()
for (const cluster of clusters) {
const clusterMessages = rankedMessages.filter(msg =>
cluster.members?.includes(msg.id)
)
if (clusterMessages.length > 0) {
const best = clusterMessages.reduce((a, b) =>
a.relevanceScore > b.relevanceScore ? a : b
)
kept.add(best.id)
}
}
finalMessages = rankedMessages.filter(msg => kept.has(msg.id))
} catch (error) {
// Clustering failed, use all messages
console.warn('Clustering failed:', error)
}
}
// Limit by token budget
let totalTokens = 0
const messagesWithinBudget: RankedMessage[] = []
for (const msg of finalMessages) {
const tokens = msg.metadata.tokensUsed || Math.ceil(msg.content.length / 4)
if (totalTokens + tokens <= maxTokens) {
messagesWithinBudget.push(msg)
totalTokens += tokens
} else {
break
}
}
// Get artifacts if requested (REAL VFS)
let artifacts: any[] = []
if (includeArtifacts && this._vfs) {
const artifactIds = new Set(
messagesWithinBudget.flatMap(msg => msg.metadata.artifacts || [])
)
for (const artifactId of artifactIds) {
try {
const entity = await this.brain.get(artifactId)
if (entity) {
artifacts.push({
id: artifactId,
path: entity.metadata?.path || artifactId,
summary: entity.metadata?.description || undefined
})
}
} catch (error) {
// Artifact not found, skip
continue
}
}
}
// Get similar conversations if requested
let similarConversations: any[] = []
if (includeSimilarConversations && conversationId && this.brain.neural) {
// Use neural neighbors (REAL)
try {
const neighborsResult = await this.brain.neural().neighbors(conversationId, {
limit: 5,
minSimilarity: 0.7
})
similarConversations = neighborsResult.neighbors.map((neighbor: any) => ({
id: neighbor.id,
title: neighbor.metadata?.title,
summary: neighbor.metadata?.summary,
relevance: neighbor.score,
messageCount: neighbor.metadata?.messageCount || 0
}))
} catch (error) {
// Neighbors failed, skip
console.warn('Similar conversation search failed:', error)
}
}
const queryTime = Date.now() - startTime
return {
messages: messagesWithinBudget.slice(0, limit),
artifacts,
similarConversations,
totalTokens,
metadata: {
queryTime,
messagesConsidered: results.length,
conversationsSearched: new Set(results.map((r: any) => r.metadata.conversationId)).size
}
}
}
/**
* Search messages semantically
*
* Uses: brain.find() with semantic search
* Real implementation - vector similarity search
*
* @param options Search options
* @returns Search results with scores
*/
async searchMessages(options: ConversationSearchOptions): Promise<ConversationSearchResult[]> {
if (!this.initialized) {
await this.init()
}
const {
query,
limit = 10,
role,
conversationId,
sessionId,
timeRange,
includeMetadata = true,
includeContent = true
} = options
// Build filter
const whereFilter: any = {}
if (role) {
whereFilter.role = Array.isArray(role) ? { $in: role } : role
}
if (conversationId) {
whereFilter.conversationId = conversationId
}
if (sessionId) {
whereFilter.sessionId = sessionId
}
if (timeRange) {
if (timeRange.start) {
whereFilter.timestamp = { $gte: timeRange.start }
}
if (timeRange.end) {
whereFilter.timestamp = { ...whereFilter.timestamp, $lte: timeRange.end }
}
}
// Search with Triple Intelligence (REAL)
const results = await this.brain.find({
query: query,
where: whereFilter,
limit
})
// Format results
return results.map((result: any) => {
const message: ConversationMessage = {
id: result.id,
content: includeContent ? (result.data || result.content || '') : '',
role: result.metadata.role,
metadata: includeMetadata ? (result.metadata as ConversationMessageMetadata) : {} as any,
embedding: result.embedding,
createdAt: result.metadata.timestamp || Date.now(),
updatedAt: result.metadata.timestamp || Date.now()
}
// Create snippet
const content = result.data || result.content || ''
const snippet = content.length > 150 ? content.substring(0, 147) + '...' : content
return {
message,
score: result.score || 0,
conversationId: result.metadata.conversationId,
snippet: includeContent ? snippet : undefined
}
})
}
/**
* Find similar conversations using Neural API
*
* Uses: brain.neural.neighbors()
* Real implementation - semantic similarity with embeddings
*
* @param conversationId Conversation ID to find similar to
* @param limit Maximum number of similar conversations
* @param threshold Minimum similarity threshold
* @returns Similar conversations with relevance scores
*/
async findSimilarConversations(
conversationId: string,
limit: number = 5,
threshold: number = 0.7
): Promise<Array<{ id: string; relevance: number; metadata?: any }>> {
if (!this.initialized) {
await this.init()
}
if (!this.brain.neural) {
throw new Error('Neural API not available')
}
// Use neural neighbors (REAL)
const neighborsResult = await this.brain.neural().neighbors(conversationId, {
limit: limit,
minSimilarity: threshold
})
return neighborsResult.neighbors.map((neighbor: any) => ({
id: neighbor.id,
relevance: neighbor.score,
metadata: neighbor.metadata
}))
}
/**
* Get conversation themes via clustering
*
* Uses: brain.neural.clusters()
* Real implementation - semantic clustering
*
* @param conversationId Conversation ID
* @returns Discovered themes
*/
async getConversationThemes(conversationId: string): Promise<ConversationTheme[]> {
if (!this.initialized) {
await this.init()
}
if (!this.brain.neural) {
throw new Error('Neural API not available')
}
// Get messages for conversation
const results = await this.brain.find({
where: { conversationId },
limit: 1000
})
if (results.length === 0) {
return []
}
// Cluster messages (REAL)
const clusters = await this.brain.neural().clusters({
maxClusters: Math.min(5, Math.ceil(results.length / 5)),
threshold: 0.75
})
// Convert to themes
return clusters.map((cluster: any, index: number) => ({
id: `theme_${index}`,
label: cluster.label || `Theme ${index + 1}`,
messages: cluster.members || [],
centroid: cluster.centroid || [],
coherence: cluster.coherence || 0
}))
}
/**
* Save an artifact (code, file, etc.) to VFS
*
* Uses: brain.vfs()
* Real implementation - stores in virtual filesystem
*
* @param path VFS path
* @param content File content
* @param options Artifact options
* @returns Artifact entity ID
*/
async saveArtifact(
path: string,
content: string | Buffer,
options: ArtifactOptions
): Promise<string> {
if (!this.initialized) {
await this.init()
}
if (!this._vfs) {
throw new Error('VFS not available')
}
// Write file to VFS (REAL)
await this._vfs.writeFile(path, content)
// Get the file entity
const entity = await this._vfs.getEntity(path)
// Link to conversation message if provided
if (options.messageId) {
await this.brain.relate({
from: options.messageId,
to: entity.id,
type: VerbType.Creates,
metadata: {
conversationId: options.conversationId,
artifactType: options.type || 'other'
}
})
}
return entity.id
}
/**
* Get conversation statistics
*
* Uses: brain.find() with aggregations
* Real implementation - queries and aggregates data
*
* @param conversationId Optional conversation ID to filter
* @returns Conversation statistics
*/
async getConversationStats(conversationId?: string): Promise<ConversationStats> {
if (!this.initialized) {
await this.init()
}
// Query messages
const whereFilter = conversationId ? { conversationId } : {}
const results = await this.brain.find({
where: whereFilter,
limit: 100000 // Large limit for stats
})
// Calculate statistics (REAL aggregation)
const conversations = new Set(results.map((r: any) => r.metadata.conversationId))
const totalMessages = results.length
const totalTokens = results.reduce(
(sum: number, r: any) => sum + (r.metadata.tokensUsed || 0),
0
)
const timestamps = results.map((r: any) => r.metadata.timestamp || Date.now())
const oldestMessage = Math.min(...timestamps)
const newestMessage = Math.max(...timestamps)
// Count by phase
const phases: Record<string, number> = {}
const roles: Record<string, number> = {}
for (const result of results) {
const phase = result.entity.metadata.problemSolvingPhase
const role = result.entity.metadata.role
if (phase) {
phases[phase] = (phases[phase] || 0) + 1
}
if (role) {
roles[role] = (roles[role] || 0) + 1
}
}
return {
totalConversations: conversations.size,
totalMessages,
totalTokens,
averageMessagesPerConversation: totalMessages / Math.max(1, conversations.size),
averageTokensPerMessage: totalTokens / Math.max(1, totalMessages),
oldestMessage,
newestMessage,
phases: phases as any,
roles: roles as any
}
}
/**
* Delete a message
*
* Uses: brain.deleteNoun()
* Real implementation - removes from graph
*
* @param messageId Message ID to delete
*/
async deleteMessage(messageId: string): Promise<void> {
if (!this.initialized) {
await this.init()
}
await this.brain.delete(messageId)
}
/**
* Export conversation to JSON
*
* Uses: getConversationThread()
* Real implementation - serializes conversation
*
* @param conversationId Conversation ID
* @returns JSON-serializable conversation object
*/
async exportConversation(conversationId: string): Promise<any> {
if (!this.initialized) {
await this.init()
}
const thread = await this.getConversationThread(conversationId, {
includeArtifacts: true
})
return {
version: '1.0',
exportedAt: Date.now(),
conversation: thread
}
}
/**
* Import conversation from JSON
*
* Uses: saveMessage() and linkMessages()
* Real implementation - recreates conversation
*
* @param data Exported conversation data
* @returns New conversation ID
*/
async importConversation(data: any): Promise<string> {
if (!this.initialized) {
await this.init()
}
const newConversationId = `conv_${uuidv4()}`
const conversation = data.conversation
if (!conversation || !conversation.messages) {
throw new Error('Invalid conversation data')
}
// Import messages in order
const messageIdMap = new Map<string, string>()
for (let i = 0; i < conversation.messages.length; i++) {
const msg = conversation.messages[i]
const prevMessageId = i > 0 ? messageIdMap.get(conversation.messages[i - 1].id) : undefined
const newMessageId = await this.saveMessage(msg.content, msg.role, {
conversationId: newConversationId,
sessionId: conversation.metadata.sessionId,
phase: msg.metadata.problemSolvingPhase,
confidence: msg.metadata.confidence,
tags: msg.metadata.tags,
linkToPrevious: prevMessageId,
metadata: msg.metadata
})
messageIdMap.set(msg.id, newMessageId)
}
return newConversationId
}
}
/**
* Create a ConversationManager instance
*
* @param brain Brainy instance
* @returns ConversationManager instance
*/
export function createConversationManager(brain: Brainy): ConversationManager {
return new ConversationManager(brain)
}